From b326d708529026a1b119635e8ee439eac91c7d33 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:12:58 -0400 Subject: [PATCH] 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. --- decnet_web/src/components/Credentials.tsx | 360 ++++------------------ decnet_web/vite.config.ts | 16 +- 2 files changed, 60 insertions(+), 316 deletions(-) diff --git a/decnet_web/src/components/Credentials.tsx b/decnet_web/src/components/Credentials.tsx index 30873735..b9865dff 100644 --- a/decnet_web/src/components/Credentials.tsx +++ b/decnet_web/src/components/Credentials.tsx @@ -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 { - Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR, - Target, RefreshCw, + Lock, Search, ChevronLeft, ChevronRight, Filter, RefreshCw, } from '../icons'; -import api from '../utils/api'; import CredentialsInspector from './CredentialsInspector'; -import type { CredentialEntry } from './CredentialsInspector'; import CredentialReuseInspector from './CredentialReuseInspector'; -import type { CredentialReuseRow } from './CredentialReuseInspector'; -import EmptyState from './EmptyState/EmptyState'; 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 './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 navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -34,87 +26,19 @@ const Credentials: React.FC = () => { const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab; const page = parseInt(searchParams.get('page') || '1'); - const [creds, setCreds] = useState([]); - const [credsTotal, setCredsTotal] = useState(0); - const [reuseRows, setReuseRows] = useState([]); - const [reuseTotal, setReuseTotal] = useState(0); - const [reuseMap, setReuseMap] = useState>(new Map()); - const [loading, setLoading] = useState(true); const [searchInput, setSearchInput] = useState(query); const searchRef = useRef(null); useFocusSearch(searchRef); + const [selectedCred, setSelectedCred] = useState(null); const [selectedReuse, setSelectedReuse] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [sortCol, setSortCol] = useState(''); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [sortDir, setSortDir] = useState('asc'); - // ── Fetch credentials (CREDS tab + always for badge totals) - useEffect(() => { - if (tab !== 'creds') return; - 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(); - (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 { + creds, credsTotal, reuseRows, reuseTotal, reuseMap, loading, fetchReuseDetail, + } = useCredentials({ tab, page, query, serviceFilter, refreshTick }); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -131,81 +55,37 @@ const Credentials: React.FC = () => { const total = tab === 'creds' ? credsTotal : reuseTotal; 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 set = new Set(); - creds.forEach(c => set.add(c.service)); + creds.forEach((c) => set.add(c.service)); return Array.from(set).sort(); }, [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 handleSortCol = (col: string) => { - if (sortCol === col) { - if (sortDir === 'asc') setSortDir('desc'); - else { setSortCol(''); setSortDir('asc'); } - } else { - setSortCol(col); - setSortDir('asc'); - } + const next = nextSortState({ col: sortCol, dir: sortDir }, col); + setSortCol(next.col); + setSortDir(next.dir); }; - const sortedCreds = useMemo(() => { - if (!sortCol) return creds; - return [...creds].sort((a, b) => { - 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 sortedCreds = useMemo( + () => sortCreds(creds, sortCol as Parameters[1], sortDir), + [creds, sortCol, sortDir], + ); - const sortedReuseRows = useMemo(() => { - if (!sortCol) return reuseRows; - return [...reuseRows].sort((a, b) => { - 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 }) => ( - handleSortCol(col)} - > - {children} - {sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} - + const sortedReuseRows = useMemo( + () => sortReuse(reuseRows, sortCol as Parameters[1], sortDir), + [reuseRows, sortCol, sortDir], ); const openReuseFromCred = async (key: string) => { const hit = reuseMap.get(key); if (!hit) return; - try { - const res = await api.get(`/credential-reuse/${hit.id}`); - setSelectedReuse(res.data as CredentialReuseRow); - } catch (err) { - console.error('Failed to fetch reuse detail', err); - } + const row = await fetchReuseDetail(hit.id); + if (row) setSelectedReuse(row); }; return ( @@ -244,7 +124,7 @@ const Credentials: React.FC = () => { > ALL - {services.map(svc => ( + {services.map((svc) => ( - @@ -303,162 +183,26 @@ const Credentials: React.FC = () => {
{tab === 'creds' ? ( - - - - LAST SEEN - DECKY - SVC - ATTACKER - PRINCIPAL - - KIND - HITS - - - - - - {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 ( - setSelectedCred(c)}> - - - - - - - - - - - - ); - }) : ( - - - - )} - -
SECRETREUSE
- {new Date(c.last_seen).toLocaleTimeString()} - {c.decky_name}{c.service} - { - e.stopPropagation(); - navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`); - }} - > - {c.attacker_ip} - - - {c.principal ?? } - - - {secretText} - - - - {c.secret_kind.toUpperCase()} - - - {c.attempt_count} - - {reuseHit ? ( - { - e.stopPropagation(); - openReuseFromCred(key); - }} - > - ×{reuseHit.target_count} - - ) : ( - - )} - - -
- -
+ navigate(`/attackers?q=${encodeURIComponent(ip)}`)} + onOpenReuse={openReuseFromCred} + /> ) : ( - - - - LAST SEEN - PRINCIPAL - KIND - TARGETS - ATTEMPTS - - - - - - - {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 ( - setSelectedReuse(r)}> - - - - - - - - - - ); - }) : ( - - - - )} - -
DECKIESSERVICES
- {new Date(r.last_seen).toLocaleTimeString()} - - {r.principal ?? } - - - {r.secret_kind.toUpperCase()} - - {r.target_count}{r.attempt_count} - {r.deckies.slice(0, 3).map(d => ( - {d} - ))} - {moreDeckies > 0 && +{moreDeckies}} - - {r.services.slice(0, 3).map(s => ( - {s} - ))} - {moreServices > 0 && +{moreServices}} - - -
- -
+ )}
diff --git a/decnet_web/vite.config.ts b/decnet_web/vite.config.ts index 0063bb3b..fe5505bb 100644 --- a/decnet_web/vite.config.ts +++ b/decnet_web/vite.config.ts @@ -15,15 +15,15 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'], // Baseline floors. Each refactor PR raises these; never lower. - // Phase 8 (SwarmHosts trim): page shell down from 513 to 161 LOC. - // Lifted helpers, EnrollmentWizard, and a useSwarmHosts polled - // data hook (CRUD + bundle generation). 16 new tests. Suite: - // 46 files, 223 tests, 22.9% lines / 16.97% branches. + // Phase 9 (Credentials trim): page shell down from 487 to 231 LOC. + // Lifted helpers, SortTh, CredsTable, ReuseTable, and a useCredentials + // hook (3 endpoints + reuse-map). 13 new tests. Suite: 48 files, + // 236 tests, 24.16% lines / 17.72% branches. thresholds: { - lines: 22, - functions: 19, - branches: 16, - statements: 21, + lines: 23, + functions: 20, + branches: 17, + statements: 22, }, }, },