refactor(server, scan): introduce target management with active target switching, enhance DB pool handling, and integrate target-aware task routes for improved modularity

This commit is contained in:
elipeter 2026-05-29 13:14:29 -05:00
parent acdc71cd88
commit 635b213825
40 changed files with 1810 additions and 240 deletions

View file

@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiDelete, apiGet, apiPost } from '../client';
import type { TargetView } from '../types';
export function useTargets() {
return useQuery({
queryKey: ['targets'],
queryFn: ({ signal }) => apiGet<TargetView[]>('/targets', signal),
});
}
export function useAddTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { path: string }) => apiPost<TargetView>('/targets', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['targets'] });
},
});
}
export function useSelectTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { id?: string; path?: string }) =>
apiPost<TargetView>('/targets/select', body),
onSuccess: () => {
qc.invalidateQueries();
},
});
}
export function useDeleteTarget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/targets/${encodeURIComponent(id)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['targets'] });
},
});
}

View file

@ -192,6 +192,17 @@ export interface ScanView {
metrics?: ScanMetricsSnapshot;
}
export interface TargetView {
id: string;
name: string;
path: string;
db_path: string;
last_seen_at: string;
last_scan_at?: string;
active: boolean;
exists: boolean;
}
// Scan Comparison types
export interface CompareScanInfo {
id: string;

View file

@ -8,13 +8,17 @@ import {
ConfigIcon,
ExplorerIcon,
DebugIcon,
FolderIcon,
TagIcon,
} from '../icons/Icons';
import type { FC } from 'react';
import { useEffect, useRef, useState, type FC, type FormEvent } from 'react';
import type { IconProps } from '../icons/Icons';
import { useHealth } from '../../api/queries/health';
import { useOverview } from '../../api/queries/overview';
import {
useAddTarget,
useSelectTarget,
useTargets,
} from '../../api/queries/targets';
import { useSSE } from '../../contexts/SSEContext';
interface NavItem {
@ -95,6 +99,167 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
return `nav-link${isActive ? ' active' : ''}`;
}
function targetNameFromPath(path: string) {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path || 'Project';
}
function targetInitial(name: string) {
return name.trim().charAt(0).toUpperCase() || '?';
}
function compactPath(path: string) {
return path.replace(/^\/Users\/[^/]+/, '~');
}
function TargetSwitcher({ scanRoot }: { scanRoot?: string }) {
const { data: targets = [] } = useTargets();
const addTarget = useAddTarget();
const selectTarget = useSelectTarget();
const [open, setOpen] = useState(false);
const [newPath, setNewPath] = useState('');
const menuRef = useRef<HTMLDivElement | null>(null);
const activeTarget =
targets.find((target) => target.active) ??
(scanRoot
? {
id: '__active__',
name: targetNameFromPath(scanRoot),
path: scanRoot,
active: true,
exists: true,
}
: undefined);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: MouseEvent) {
if (
menuRef.current &&
event.target instanceof Node &&
!menuRef.current.contains(event.target)
) {
setOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
function handleSelect(id: string) {
selectTarget.mutate(
{ id },
{
onSuccess: () => setOpen(false),
},
);
}
function handleAddSubmit(event: FormEvent) {
event.preventDefault();
const path = newPath.trim();
if (!path || addTarget.isPending) return;
addTarget.mutate(
{ path },
{
onSuccess: (target) => {
setNewPath('');
selectTarget.mutate(
{ id: target.id },
{
onSuccess: () => setOpen(false),
},
);
},
},
);
}
const isBusy = addTarget.isPending || selectTarget.isPending;
const errorMessage =
addTarget.error instanceof Error ? addTarget.error.message : null;
return (
<div className="target-switcher" ref={menuRef}>
<button
type="button"
className="target-trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label="Select project target"
title={activeTarget?.path}
>
<span className="target-avatar">
{targetInitial(activeTarget?.name ?? 'Project')}
</span>
<span className="target-trigger-copy">
<span className="target-name">
{activeTarget?.name ?? 'Select target'}
</span>
<span className="target-path">
{activeTarget?.path ? compactPath(activeTarget.path) : 'No target'}
</span>
</span>
<span className={`target-caret${open ? ' open' : ''}`} />
</button>
{open && (
<div className="target-menu" role="menu">
<div className="target-options">
{targets.map((target) => (
<button
key={target.id}
type="button"
className={`target-option${target.active ? ' active' : ''}`}
onClick={() => handleSelect(target.id)}
disabled={target.active || !target.exists || isBusy}
title={target.path}
>
<span className="target-option-avatar">
{targetInitial(target.name)}
</span>
<span className="target-option-copy">
<span className="target-option-name">{target.name}</span>
<span className="target-option-path">
{target.exists ? compactPath(target.path) : 'Missing path'}
</span>
</span>
</button>
))}
</div>
<form className="target-add-form" onSubmit={handleAddSubmit}>
<input
value={newPath}
onChange={(event) => setNewPath(event.target.value)}
placeholder="/path/to/project"
aria-label="Project path"
/>
<button
type="submit"
className="target-add-button"
disabled={!newPath.trim() || addTarget.isPending}
title="Add target"
aria-label="Add target"
>
+
</button>
</form>
{errorMessage && <div className="target-error">{errorMessage}</div>}
</div>
)}
</div>
);
}
export function Sidebar() {
const { data: health } = useHealth();
const { data: overview } = useOverview();
@ -112,6 +277,8 @@ export function Sidebar() {
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
</div>
<TargetSwitcher scanRoot={health?.scan_root} />
<ul className="nav-list">
{primary.map((item) => (
<li key={item.id}>
@ -161,12 +328,6 @@ export function Sidebar() {
</div>
<div className="sidebar-meta">
{health?.scan_root && (
<div className="sidebar-meta-item" title={health.scan_root}>
<FolderIcon />
<span>{health.scan_root}</span>
</div>
)}
{health?.version && (
<div className="sidebar-meta-item">
<TagIcon />

View file

@ -58,6 +58,7 @@ export function SSEProvider({ children }: { children: ReactNode }) {
es.addEventListener('scan_started', () => {
setIsScanRunning(true);
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_progress', (e) => {
@ -75,12 +76,14 @@ export function SSEProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['overview'] });
queryClient.invalidateQueries({ queryKey: ['findings'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('scan_failed', () => {
setScanProgress(null);
setIsScanRunning(false);
queryClient.invalidateQueries({ queryKey: ['scans'] });
queryClient.invalidateQueries({ queryKey: ['targets'] });
});
es.addEventListener('config_changed', () => {

View file

@ -177,6 +177,165 @@ a:hover {
color: var(--text-tertiary);
font-family: var(--font-mono);
}
.target-switcher {
position: relative;
padding: 0 var(--space-3) var(--space-2);
}
.target-trigger,
.target-option,
.target-add-button {
appearance: none;
border: 0;
font: inherit;
cursor: pointer;
}
.target-trigger {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 12px;
align-items: center;
gap: var(--space-2);
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
text-align: left;
}
.target-trigger:hover,
.target-trigger[aria-expanded='true'] {
border-color: var(--line-strong);
background: var(--bg-secondary);
}
.target-avatar,
.target-option-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent-light);
color: var(--accent);
font-weight: var(--weight-semibold);
flex-shrink: 0;
}
.target-trigger-copy,
.target-option-copy {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.25;
}
.target-name,
.target-option-name {
color: var(--text);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-path,
.target-option-path {
color: var(--text-tertiary);
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-caret {
width: 8px;
height: 8px;
border-right: 1.5px solid var(--text-tertiary);
border-bottom: 1.5px solid var(--text-tertiary);
transform: rotate(45deg) translateY(-2px);
transition: transform var(--transition-base);
}
.target-caret.open {
transform: rotate(225deg) translateY(-2px);
}
.target-menu {
position: absolute;
left: var(--space-3);
right: var(--space-3);
top: calc(100% - var(--space-1));
z-index: 30;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.target-options {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 220px;
overflow-y: auto;
}
.target-option {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 42px;
padding: 5px 6px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
}
.target-option:hover:not(:disabled) {
background: var(--bg-secondary);
}
.target-option.active {
background: var(--accent-light);
}
.target-option:disabled {
cursor: default;
opacity: 0.7;
}
.target-option-avatar {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.target-add-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
gap: var(--space-1);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-light);
}
.target-add-form input {
min-width: 0;
height: 30px;
padding: 5px 8px;
font-size: 0.75rem;
}
.target-add-button {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-contrast);
font-size: 1rem;
font-weight: var(--weight-semibold);
}
.target-add-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.target-error {
margin-top: var(--space-2);
color: var(--sev-high);
font-size: 0.72rem;
line-height: 1.3;
}
.nav-list {
list-style: none;
padding: var(--space-3) var(--space-3);