This commit is contained in:
Eli Peter 2026-06-05 10:16:30 -05:00 committed by GitHub
parent 55247b7fcd
commit 991c84a1eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1464 changed files with 225448 additions and 1985 deletions

View file

@ -17,6 +17,7 @@ import { RulesPage } from '../../pages/RulesPage';
import { TriagePage } from '../../pages/TriagePage';
import { ConfigPage } from '../../pages/ConfigPage';
import { ExplorerPage } from '../../pages/ExplorerPage';
import { SurfacePage } from '../../pages/SurfacePage';
import { DebugLayout } from '../../pages/debug/DebugLayout';
import { CallGraphPage } from '../../pages/debug/CallGraphPage';
import { SummaryExplorerPage } from '../../pages/debug/SummaryExplorerPage';
@ -50,6 +51,12 @@ export function AppLayout() {
label: 'Explorer',
to: '/explorer',
},
{
id: 'go-surface',
group: 'Navigate',
label: 'Attack surface',
to: '/surface',
},
{
id: 'go-debug-cg',
group: 'Navigate',
@ -141,6 +148,7 @@ export function AppLayout() {
<Route path="/triage" element={<TriagePage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/explorer" element={<ExplorerPage />} />
<Route path="/surface" element={<SurfacePage />} />
<Route path="/debug" element={<DebugLayout />}>
<Route
index

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 {
@ -68,6 +72,13 @@ const NAV_SECTIONS: NavItem[] = [
Icon: ExplorerIcon,
group: 'secondary',
},
{
id: 'surface',
label: 'Surface',
path: '/surface',
Icon: ExplorerIcon,
group: 'secondary',
},
{
id: 'debug',
label: 'Debug',
@ -88,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();
@ -105,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}>
@ -154,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 />