refactor(decnet_web/Credentials): wire shell + bump coverage floor

Credentials.tsx: 487 -> 231 LOC. Page now composes CredsTable +
ReuseTable + useCredentials hook; URL-derived state (tab, query,
service, page) and selection/sort UI are the only concerns left
in the shell.
This commit is contained in:
2026-05-09 06:12:58 -04:00
parent bf79581cc9
commit b326d70852
2 changed files with 60 additions and 316 deletions

View File

@@ -1,31 +1,23 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { import {
Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR, Lock, Search, ChevronLeft, ChevronRight, Filter, RefreshCw,
Target, RefreshCw,
} from '../icons'; } from '../icons';
import api from '../utils/api';
import CredentialsInspector from './CredentialsInspector'; import CredentialsInspector from './CredentialsInspector';
import type { CredentialEntry } from './CredentialsInspector';
import CredentialReuseInspector from './CredentialReuseInspector'; import CredentialReuseInspector from './CredentialReuseInspector';
import type { CredentialReuseRow } from './CredentialReuseInspector';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch'; import { useFocusSearch } from '../hooks/useFocusSearch';
import CredsTable from './Credentials/CredsTable';
import ReuseTable from './Credentials/ReuseTable';
import { useCredentials } from './Credentials/useCredentials';
import {
CREDS_LIMIT, REUSE_LIMIT, nextSortState, sortCreds, sortReuse,
} from './Credentials/helpers';
import type {
CredentialEntry, CredentialReuseRow, SortDir, Tab,
} from './Credentials/types';
import './Dashboard.css'; import './Dashboard.css';
import './Credentials.css'; import './Credentials.css';
const truncHash = (h: string | null | undefined, n = 12): string =>
h ? `${h.slice(0, n)}` : '—';
const CREDS_LIMIT = 50;
const REUSE_LIMIT = 25;
const REUSE_MAP_CAP = 500;
type Tab = 'creds' | 'reuse';
const reuseKey = (sha: string, kind: string, principal: string | null): string =>
`${sha}|${kind}|${principal ?? ''}`;
const Credentials: React.FC = () => { const Credentials: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -34,87 +26,19 @@ const Credentials: React.FC = () => {
const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab; const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab;
const page = parseInt(searchParams.get('page') || '1'); const page = parseInt(searchParams.get('page') || '1');
const [creds, setCreds] = useState<CredentialEntry[]>([]);
const [credsTotal, setCredsTotal] = useState(0);
const [reuseRows, setReuseRows] = useState<CredentialReuseRow[]>([]);
const [reuseTotal, setReuseTotal] = useState(0);
const [reuseMap, setReuseMap] = useState<Map<string, { id: string; target_count: number }>>(new Map());
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query); const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null); const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef); useFocusSearch(searchRef);
const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null); const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null);
const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null); const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null);
const [refreshTick, setRefreshTick] = useState(0); const [refreshTick, setRefreshTick] = useState(0);
const [sortCol, setSortCol] = useState<string>(''); const [sortCol, setSortCol] = useState<string>('');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortDir, setSortDir] = useState<SortDir>('asc');
// ── Fetch credentials (CREDS tab + always for badge totals) const {
useEffect(() => { creds, credsTotal, reuseRows, reuseTotal, reuseMap, loading, fetchReuseDetail,
if (tab !== 'creds') return; } = useCredentials({ tab, page, query, serviceFilter, refreshTick });
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * CREDS_LIMIT;
let url = `/credentials?limit=${CREDS_LIMIT}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
const res = await api.get(url);
if (cancelled) return;
setCreds(res.data.data);
setCredsTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credentials', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, query, serviceFilter, page, refreshTick]);
// ── Fetch reuse rows (REUSE tab)
useEffect(() => {
if (tab !== 'reuse') return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * REUSE_LIMIT;
const res = await api.get(`/credential-reuse?limit=${REUSE_LIMIT}&offset=${offset}`);
if (cancelled) return;
setReuseRows(res.data.data);
setReuseTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credential-reuse', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, page, refreshTick]);
// ── Build reuse-map for the badge column on the CREDS tab
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api.get(`/credential-reuse?limit=${REUSE_MAP_CAP}&offset=0`);
if (cancelled) return;
const m = new Map<string, { id: string; target_count: number }>();
(res.data.data as CredentialReuseRow[]).forEach(r => {
m.set(reuseKey(r.secret_sha256, r.secret_kind, r.principal), {
id: r.id,
target_count: r.target_count,
});
});
setReuseMap(m);
} catch {
/* badge column degrades silently to "—" */
}
})();
return () => { cancelled = true; };
}, [refreshTick]);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -131,81 +55,37 @@ const Credentials: React.FC = () => {
const total = tab === 'creds' ? credsTotal : reuseTotal; const total = tab === 'creds' ? credsTotal : reuseTotal;
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
// Service chips derived from visible creds page // Service chips derived from visible creds page.
const services = useMemo(() => { const services = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
creds.forEach(c => set.add(c.service)); creds.forEach((c) => set.add(c.service));
return Array.from(set).sort(); return Array.from(set).sort();
}, [creds]); }, [creds]);
const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length; const plaintextCount = creds.filter((c) => c.secret_kind === 'plaintext').length;
const hashedCount = creds.length - plaintextCount; const hashedCount = creds.length - plaintextCount;
const handleSortCol = (col: string) => { const handleSortCol = (col: string) => {
if (sortCol === col) { const next = nextSortState({ col: sortCol, dir: sortDir }, col);
if (sortDir === 'asc') setSortDir('desc'); setSortCol(next.col);
else { setSortCol(''); setSortDir('asc'); } setSortDir(next.dir);
} else {
setSortCol(col);
setSortDir('asc');
}
}; };
const sortedCreds = useMemo(() => { const sortedCreds = useMemo(
if (!sortCol) return creds; () => sortCreds(creds, sortCol as Parameters<typeof sortCreds>[1], sortDir),
return [...creds].sort((a, b) => { [creds, sortCol, sortDir],
let av: string | number = ''; );
let bv: string | number = '';
if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; }
else if (sortCol === 'decky') { av = a.decky_name; bv = b.decky_name; }
else if (sortCol === 'svc') { av = a.service; bv = b.service; }
else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; }
else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
else if (sortCol === 'hits') { av = a.attempt_count; bv = b.attempt_count; }
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [creds, sortCol, sortDir]);
const sortedReuseRows = useMemo(() => { const sortedReuseRows = useMemo(
if (!sortCol) return reuseRows; () => sortReuse(reuseRows, sortCol as Parameters<typeof sortReuse>[1], sortDir),
return [...reuseRows].sort((a, b) => { [reuseRows, sortCol, sortDir],
let av: string | number = '';
let bv: string | number = '';
if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; }
else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
else if (sortCol === 'targets') { av = a.target_count; bv = b.target_count; }
else if (sortCol === 'attempts') { av = a.attempt_count; bv = b.attempt_count; }
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [reuseRows, sortCol, sortDir]);
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
<th
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => handleSortCol(col)}
>
{children}
{sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
</th>
); );
const openReuseFromCred = async (key: string) => { const openReuseFromCred = async (key: string) => {
const hit = reuseMap.get(key); const hit = reuseMap.get(key);
if (!hit) return; if (!hit) return;
try { const row = await fetchReuseDetail(hit.id);
const res = await api.get(`/credential-reuse/${hit.id}`); if (row) setSelectedReuse(row);
setSelectedReuse(res.data as CredentialReuseRow);
} catch (err) {
console.error('Failed to fetch reuse detail', err);
}
}; };
return ( return (
@@ -244,7 +124,7 @@ const Credentials: React.FC = () => {
> >
ALL ALL
</button> </button>
{services.map(svc => ( {services.map((svc) => (
<button <button
key={svc} key={svc}
type="button" type="button"
@@ -294,7 +174,7 @@ const Credentials: React.FC = () => {
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page"> <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} /> <ChevronRight size={14} />
</button> </button>
<button onClick={() => setRefreshTick(t => t + 1)} aria-label="Refresh"> <button onClick={() => setRefreshTick((t) => t + 1)} aria-label="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
@@ -303,162 +183,26 @@ const Credentials: React.FC = () => {
<div className="logs-table-container"> <div className="logs-table-container">
{tab === 'creds' ? ( {tab === 'creds' ? (
<table className="logs-table"> <CredsTable
<thead> rows={sortedCreds}
<tr> reuseMap={reuseMap}
<SortTh col="seen">LAST SEEN</SortTh> loading={loading}
<SortTh col="decky">DECKY</SortTh> sortCol={sortCol}
<SortTh col="svc">SVC</SortTh> sortDir={sortDir}
<SortTh col="attacker">ATTACKER</SortTh> onSort={handleSortCol}
<SortTh col="principal">PRINCIPAL</SortTh> onSelectCred={setSelectedCred}
<th>SECRET</th> onSelectAttacker={(ip) => navigate(`/attackers?q=${encodeURIComponent(ip)}`)}
<SortTh col="kind">KIND</SortTh> onOpenReuse={openReuseFromCred}
<SortTh col="hits">HITS</SortTh> />
<th>REUSE</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedCreds.length > 0 ? sortedCreds.map(c => {
const isPlain = c.secret_kind === 'plaintext';
const secretText = isPlain
? (c.secret_printable ?? '—')
: truncHash(c.secret_sha256, 16);
const key = reuseKey(c.secret_sha256, c.secret_kind, c.principal);
const reuseHit = reuseMap.get(key);
return (
<tr key={c.id} className="clickable" onClick={() => setSelectedCred(c)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(c.last_seen).toLocaleTimeString()}
</td>
<td className="violet-accent">{c.decky_name}</td>
<td><span className="chip dim-chip">{c.service}</span></td>
<td>
<span
className="matrix-text attacker-link"
onClick={(e) => {
e.stopPropagation();
navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`);
}}
>
{c.attacker_ip}
</span>
</td>
<td className="principal-cell">
{c.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`secret-cell${isPlain ? '' : ' hashed'}`} title={secretText}>
{secretText}
</span>
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{c.secret_kind.toUpperCase()}
</span>
</td>
<td>
<span className="attempt-pill">{c.attempt_count}</span>
</td>
<td>
{reuseHit ? (
<span
className="attempt-pill"
style={{ cursor: 'pointer', color: 'var(--violet)' }}
title="Open reuse finding"
onClick={(e) => {
e.stopPropagation();
openReuseFromCred(key);
}}
>
×{reuseHit.target_count}
</span>
) : (
<span className="dim"></span>
)}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={10}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING CREDENTIALS…' : 'NO CREDENTIALS YET'}
hint={loading ? undefined : 'captured auth attempts will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
) : ( ) : (
<table className="logs-table"> <ReuseTable
<thead> rows={sortedReuseRows}
<tr> loading={loading}
<SortTh col="seen">LAST SEEN</SortTh> sortCol={sortCol}
<SortTh col="principal">PRINCIPAL</SortTh> sortDir={sortDir}
<SortTh col="kind">KIND</SortTh> onSort={handleSortCol}
<SortTh col="targets">TARGETS</SortTh> onSelect={setSelectedReuse}
<SortTh col="attempts">ATTEMPTS</SortTh> />
<th>DECKIES</th>
<th>SERVICES</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedReuseRows.length > 0 ? sortedReuseRows.map(r => {
const isPlain = r.secret_kind === 'plaintext';
const moreDeckies = Math.max(0, r.deckies.length - 3);
const moreServices = Math.max(0, r.services.length - 3);
return (
<tr key={r.id} className="clickable" onClick={() => setSelectedReuse(r)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(r.last_seen).toLocaleTimeString()}
</td>
<td className="principal-cell">
{r.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{r.secret_kind.toUpperCase()}
</span>
</td>
<td><span className="attempt-pill">{r.target_count}</span></td>
<td><span className="attempt-pill">{r.attempt_count}</span></td>
<td>
{r.deckies.slice(0, 3).map(d => (
<span key={d} className="chip dim-chip" style={{ marginRight: 4 }}>{d}</span>
))}
{moreDeckies > 0 && <span className="dim">+{moreDeckies}</span>}
</td>
<td>
{r.services.slice(0, 3).map(s => (
<span key={s} className="chip dim-chip" style={{ marginRight: 4 }}>{s}</span>
))}
{moreServices > 0 && <span className="dim">+{moreServices}</span>}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={8}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING REUSE…' : 'NO REUSE FINDINGS YET'}
hint={loading ? undefined : 'a credential captured on ≥2 deckies will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
)} )}
</div> </div>
</div> </div>

View File

@@ -15,15 +15,15 @@ export default defineConfig({
include: ['src/**/*.{ts,tsx}'], include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'], exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
// Baseline floors. Each refactor PR raises these; never lower. // Baseline floors. Each refactor PR raises these; never lower.
// Phase 8 (SwarmHosts trim): page shell down from 513 to 161 LOC. // Phase 9 (Credentials trim): page shell down from 487 to 231 LOC.
// Lifted helpers, EnrollmentWizard, and a useSwarmHosts polled // Lifted helpers, SortTh, CredsTable, ReuseTable, and a useCredentials
// data hook (CRUD + bundle generation). 16 new tests. Suite: // hook (3 endpoints + reuse-map). 13 new tests. Suite: 48 files,
// 46 files, 223 tests, 22.9% lines / 16.97% branches. // 236 tests, 24.16% lines / 17.72% branches.
thresholds: { thresholds: {
lines: 22, lines: 23,
functions: 19, functions: 20,
branches: 16, branches: 17,
statements: 21, statements: 22,
}, },
}, },
}, },