refactor: Update UI components for consistency and improve layout

This commit is contained in:
elipeter 2026-05-06 04:38:04 -04:00
parent da619171cf
commit 77be7f10d9
74 changed files with 3186 additions and 618 deletions

View file

@ -5,7 +5,7 @@ let csrfTokenPromise: Promise<string> | null = null;
export class ApiError extends Error {
/**
* Stable machine-readable code (matches backend `ApiError`'s `code` field).
* Falls back to a synthetic value when the response wasn't structured
* Falls back to a synthetic value when the response was not structured,
* `network` for fetch failures, `http_<status>` for plain-text responses.
*/
public code: string;
@ -49,7 +49,7 @@ async function errorFromResponse(res: Response): Promise<ApiError> {
const code = typeof parsed.code === 'string' ? parsed.code : undefined;
return new ApiError(res.status, msg, code, parsed.detail);
} catch {
// Plain-text body use as-is.
// Plain-text body, use as-is.
return new ApiError(res.status, text);
}
}

View file

@ -23,10 +23,10 @@ export function HorizontalBarChart({
);
}
const barH = 22;
const gap = 4;
const labelW = 110;
const valueW = 45;
const barH = 32;
const gap = 12;
const labelW = 120;
const valueW = 48;
const barAreaW = width - labelW - valueW - 16;
const totalH = items.length * (barH + gap);
const maxVal = maxValue ?? Math.max(...items.map((i) => i.value), 1);
@ -49,7 +49,7 @@ export function HorizontalBarChart({
x={labelW - 8}
y={y + barH / 2 + 4}
textAnchor="end"
fontSize={11}
fontSize={13}
fontFamily="var(--font)"
fill="var(--text-secondary)"
>
@ -68,7 +68,7 @@ export function HorizontalBarChart({
x={labelW + barAreaW + 8}
y={y + barH / 2 + 4}
textAnchor="start"
fontSize={11}
fontSize={13}
fontFamily="var(--font-mono)"
fontWeight={600}
fill="var(--text)"

View file

@ -16,7 +16,7 @@ export function LineChart({
points,
color = 'var(--accent)',
width = 400,
height = 160,
height = 240,
}: LineChartProps) {
if (!points || points.length < 2) {
return (

View file

@ -111,7 +111,7 @@ export function HeaderBar({ onStartScan, onOpenPalette }: HeaderBarProps) {
className="btn btn-primary btn-sm"
onClick={onStartScan}
>
Start Scan
Start scan
</button>
)}
</div>

View file

@ -14,6 +14,7 @@ import {
import type { FC } from 'react';
import type { IconProps } from '../icons/Icons';
import { useHealth } from '../../api/queries/health';
import { useOverview } from '../../api/queries/overview';
import { useSSE } from '../../contexts/SSEContext';
interface NavItem {
@ -89,17 +90,19 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
export function Sidebar() {
const { data: health } = useHealth();
const { data: overview } = useOverview();
const { isScanRunning } = useSSE();
const primary = NAV_SECTIONS.filter((n) => n.group === 'primary');
const secondary = NAV_SECTIONS.filter((n) => n.group === 'secondary');
const footer = NAV_SECTIONS.filter((n) => n.group === 'footer');
const findingsCount =
overview && overview.state !== 'empty' ? overview.total_findings : null;
return (
<aside className="sidebar">
<div className="sidebar-header">
<span className="logo">nyx</span>
{health?.version && <span className="version">v{health.version}</span>}
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
</div>
<ul className="nav-list">
@ -113,12 +116,15 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
{item.id === 'findings' && findingsCount != null && (
<span className="nav-badge">{findingsCount}</span>
)}
</NavLink>
</li>
))}
<li className="nav-separator" />
<li className="nav-section-header">Tools</li>
{secondary.map((item) => (
<li key={item.id}>
@ -126,7 +132,7 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
</NavLink>
</li>
))}
@ -140,7 +146,7 @@ export function Sidebar() {
<span className="nav-icon">
<item.Icon />
</span>
<span>{item.label}</span>
<span className="nav-label">{item.label}</span>
</NavLink>
</li>
))}

View file

@ -1,3 +1,4 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type {
HealthScore,
@ -25,8 +26,17 @@ export function HealthScoreCard({
posture?: PostureSummary;
}) {
const gradeClass = `grade-${health.grade.toLowerCase()}`;
const gradeAccent =
health.grade === 'A' || health.grade === 'B'
? 'var(--green)'
: health.grade === 'C'
? 'var(--amber)'
: 'var(--red)';
return (
<div className="card health-card">
<div
className="card health-card"
style={{ '--health-accent': gradeAccent } as React.CSSProperties}
>
<div className="health-eyebrow">Health Score</div>
<div className="health-headline">
<div className={`health-grade-block ${gradeClass}`}>
@ -44,12 +54,26 @@ export function HealthScoreCard({
)}
</div>
<div className="health-components">
{health.components.map((c) => (
<div className="health-component" key={c.label} title={c.detail}>
<div className="health-component-score">{c.score}</div>
<div className="health-component-label">{c.label}</div>
</div>
))}
{health.components.map((c) => {
const barColor =
c.score >= 70
? 'var(--green)'
: c.score >= 40
? 'var(--amber)'
: 'var(--red)';
return (
<div className="health-component" key={c.label} title={c.detail}>
<div className="health-component-label">{c.label}</div>
<div className="health-component-bar-track">
<div
className="health-component-fill"
style={{ width: `${c.score}%`, background: barColor }}
/>
</div>
<div className="health-component-score">{c.score}</div>
</div>
);
})}
</div>
</div>
</div>
@ -113,7 +137,13 @@ export function BacklogCard({ backlog }: { backlog: BacklogStats }) {
function BucketBar({ buckets }: { buckets: OverviewCount[] }) {
const total = buckets.reduce((s, b) => s + b.count, 0);
if (total === 0) return null;
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e67e22', '#e74c3c'];
const colors = [
'var(--accent)',
'var(--green)',
'var(--amber)',
'var(--red)',
'var(--muted)',
];
return (
<div
className="bucket-bar"
@ -149,10 +179,10 @@ export function ConfidenceDistributionChart({
);
}
const segments = [
{ label: 'High', value: dist.high, color: '#27ae60' },
{ label: 'Medium', value: dist.medium, color: '#f39c12' },
{ label: 'Low', value: dist.low, color: '#95a5a6' },
{ label: 'None', value: dist.none, color: '#bdc3c7' },
{ label: 'High', value: dist.high, color: 'var(--green)' },
{ label: 'Medium', value: dist.medium, color: 'var(--amber)' },
{ label: 'Low', value: dist.low, color: 'var(--muted)' },
{ label: 'None', value: dist.none, color: 'var(--subtle)' },
];
return (
<div className="confidence-dist">
@ -484,7 +514,7 @@ export function SuppressionHygieneCard({
<div className="kv-row kv-row-emphasis">
<dt
className="kv-label"
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better — it means triage is decisive rather than blanket-silencing whole rules or files."
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better because triage is decisive rather than blanket-silencing whole rules or files."
>
Blanket rate
<span className="kv-hint">Lower is better</span>

View file

@ -12,7 +12,7 @@ export interface PaletteCommand {
id: string;
/** Visible label. */
label: string;
/** Optional secondary line — section, hint, shortcut. */
/** Optional secondary line such as section, hint, or shortcut. */
hint?: string;
/** Group label for visual separation. */
group?: string;

View file

@ -3,7 +3,7 @@ interface LoadingStateProps {
/**
* Suppresses the spinner for the first ~150ms so trivially-fast queries
* don't flash a spinner on screen. The text shows instantly so there's
* always *something* but the visible spin only kicks in if work is
* always something, but the visible spin only kicks in if work is
* actually slow.
*/
delaySpinnerMs?: number;

View file

@ -38,7 +38,7 @@ function resolve(pref: ThemePreference): ResolvedTheme {
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setPreference] = usePersistedState<ThemePreference>(
'theme',
'system',
'light',
);
const resolved = useMemo(() => resolve(preference), [preference]);

View file

@ -24,7 +24,7 @@ interface ToastContextValue {
t: Omit<Toast, 'id' | 'durationMs'> & { durationMs?: number },
) => number;
dismiss: (id: number) => void;
/** Convenience helpers — call sites read more naturally as toast.error('…'). */
/** Convenience helpers so call sites read naturally as toast.error('...'). */
info: (message: string, title?: string) => number;
success: (message: string, title?: string) => number;
warning: (message: string, title?: string) => number;
@ -37,7 +37,7 @@ const DEFAULT_DURATION: Record<ToastTone, number> = {
info: 4000,
success: 4000,
warning: 6000,
// Error toasts stick longer failures usually need a deliberate read.
// Error toasts stick longer because failures usually need a deliberate read.
error: 8000,
};

View file

@ -18,20 +18,20 @@ export interface EdgeStyle {
}
const FALLBACK_PALETTE: GraphThemePalette = {
background: '#ffffff',
backgroundSecondary: '#f7f7f8',
text: '#1a1a1a',
textSecondary: '#6b6b76',
textTertiary: '#9b9ba7',
border: '#e5e5ea',
borderLight: '#f0f0f4',
accent: '#72f3d7',
accentSoft: 'rgba(114, 243, 215, 0.16)',
success: '#2ecc71',
warning: '#e67e22',
danger: '#e74c3c',
neutral: '#607187',
neutralSoft: '#8c99ab',
background: '#f9f8f4',
backgroundSecondary: '#f2f0ea',
text: '#0d0c0a',
textSecondary: '#3c3830',
textTertiary: '#6c6660',
border: '#e5e1d7',
borderLight: '#ede9df',
accent: '#0b3d2a',
accentSoft: '#ecf3ee',
success: '#1c5c38',
warning: '#8c6310',
danger: '#9d2f25',
neutral: '#6c6660',
neutralSoft: '#9c9690',
};
function readVar(name: string, fallback: string): string {
@ -150,14 +150,14 @@ function cfgNodeStyle(
};
case 'Loop':
return {
fill: '#4f78c2',
stroke: '#3c5f9a',
fill: '#6c6660',
stroke: '#3c3830',
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2.1,
accentFill: palette.accent,
neighborFill: withAlpha('#4f78c2', 0.74),
neighborFill: withAlpha('#6c6660', 0.74),
};
case 'Call':
return {
@ -200,8 +200,8 @@ function callGraphNodeStyle(
metadata?: GraphMetadata,
): NodeStyle {
const isRecursive = metadata?.isRecursive === true;
const fill = isRecursive ? '#7d6450' : palette.neutral;
const stroke = isRecursive ? '#6a5444' : withAlpha(palette.neutral, 0.84);
const fill = isRecursive ? '#5a5042' : palette.neutral;
const stroke = isRecursive ? '#3c3830' : withAlpha(palette.neutral, 0.84);
return {
fill,
@ -245,7 +245,7 @@ export function getEdgeStyle(
case 'False':
return { color: palette.danger, width: 1.8, dash: [] };
case 'Back':
return { color: '#4f78c2', width: 1.6, dash: [7, 4] };
return { color: palette.textTertiary, width: 1.6, dash: [7, 4] };
case 'Exception':
return { color: palette.warning, width: 1.6, dash: [3, 3] };
default:

View file

@ -31,7 +31,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
};
/** Subset of state we remember across sessions. Filters intentionally are
* NOT persisted they're scan-specific and should reset by default, but the
* NOT persisted because they're scan-specific and should reset by default, but the
* URL still reflects them so a shared link reproduces them exactly. */
interface PersistedFindingsPrefs {
per_page: string;

View file

@ -13,7 +13,7 @@ export interface Shortcut {
handler: (event: KeyboardEvent) => void;
/**
* If true, the shortcut still fires when focus is in an input/textarea/
* contenteditable. Default is false shortcuts shouldn't hijack typing.
* contenteditable. Default is false, so shortcuts should not hijack typing.
*/
allowInInput?: boolean;
}

View file

@ -20,7 +20,7 @@ function write<T>(key: string, value: T): void {
try {
window.localStorage.setItem(storageKey(key), JSON.stringify(value));
} catch {
// Quota exceeded or storage disabled silently degrade.
// Quota exceeded or storage disabled, so silently degrade.
}
}
@ -28,7 +28,7 @@ function write<T>(key: string, value: T): void {
* `useState` that persists to `localStorage` under `nyx:<key>`.
*
* Suitable for view preferences (theme, sidebar collapse, default page size).
* Not suitable for sensitive data `localStorage` is not encrypted.
* Not suitable for sensitive data because `localStorage` is not encrypted.
*
* Cross-tab sync is not implemented; if the user opens two tabs they get
* independent state until next load. That's the common-case ergonomic.

View file

@ -18,7 +18,7 @@ interface NewScanModalProps {
const MODE_HINTS: Record<ScanMode, string> = {
full: 'AST + CFG + taint (default)',
ast: 'AST patterns only — fastest',
ast: 'AST patterns only. Fastest.',
cfg: 'CFG structural + taint',
taint: 'Taint flows only',
};
@ -26,7 +26,7 @@ const MODE_HINTS: Record<ScanMode, string> = {
const PROFILE_HINTS: Record<EngineProfile, string> = {
fast: 'Basic taint. No abstract-interp / context-sensitive / symex / backwards.',
balanced: 'Default. Adds abstract-interp + context-sensitive inlining.',
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. ~23× slower.',
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. About 2 to 3x slower.',
};
export function NewScanModal({ open, onClose }: NewScanModalProps) {
@ -67,7 +67,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
return (
<Modal open={open} onClose={onClose} className="scan-modal-overlay">
<div className="scan-modal">
<h3>Start New Scan</h3>
<h3>Start new scan</h3>
<div className="scan-modal-form">
<div className="form-group">
<label>Scan Root</label>
@ -114,7 +114,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
onClick={handleStart}
disabled={startScan.isPending}
>
{startScan.isPending ? 'Starting...' : 'Start Scan'}
{startScan.isPending ? 'Starting...' : 'Start scan'}
</button>
</div>
</div>

View file

@ -170,7 +170,7 @@ function KvGrid({ entries }: { entries: Array<[string, React.ReactNode]> }) {
}
function fmt(v: unknown): React.ReactNode {
if (v === null || v === undefined) return <span className="muted"></span>;
if (v === null || v === undefined) return <span className="muted">-</span>;
if (typeof v === 'boolean')
return (
<span className={v ? 'pill pill-on' : 'pill pill-off'}>
@ -387,7 +387,7 @@ function RawEditor() {
value={draft ?? ''}
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
placeholder="# nyx.local overrides for the default config.&#10;# Anything you set here wins over nyx.conf."
placeholder="# nyx.local - overrides for the default config.&#10;# Anything you set here wins over nyx.conf."
/>
<p className="config-help">
Edits are validated against the full config schema before being written.
@ -608,7 +608,7 @@ export function ConfigPage() {
setProfileName('');
}, [profileName, addProfile]);
if (configLoading) return <LoadingState message="Loading configuration" />;
if (configLoading) return <LoadingState message="Loading configuration..." />;
if (configError) return <ErrorState message={configError.message} />;
const cfg = config as Record<string, Record<string, unknown>> | undefined;
@ -616,15 +616,7 @@ export function ConfigPage() {
const triageSyncOn = !!server?.triage_sync;
return (
<>
<div className="page-header">
<h2>Config</h2>
<span className="page-header-sub">
Edit defaults, rules, profiles, and the raw <code>nyx.local</code>{' '}
file
</span>
</div>
<div className="config-page page-shell">
<div className="config-tabs">
{(
[
@ -866,6 +858,6 @@ export function ConfigPage() {
)}
{tab === 'raw' && <RawEditor />}
</>
</div>
);
}

View file

@ -67,7 +67,7 @@ const STATE_REMEDIATION_HINTS: Record<string, string[]> = {
'Prefer a language-native cleanup pattern (defer, with, try-with-resources, RAII).',
],
'state-resource-leak-possible': [
'Ensure the resource is closed on all code paths including error and early-return paths.',
'Ensure the resource is closed on all code paths, including error and early-return paths.',
'Put cleanup in a finally/defer block rather than after the happy path.',
],
'state-unauthed-access': [
@ -636,7 +636,7 @@ const TAINT_REMEDIATION: Record<string, string[]> = {
'If HTML is unavoidable, run input through a well-maintained sanitizer (DOMPurify, Bleach).',
],
sql: [
'Use parameterized queries or a prepared statement — never concatenate user input into SQL.',
'Use parameterized queries or a prepared statement. Never concatenate user input into SQL.',
'Prefer an ORM or query builder that escapes parameters automatically.',
'Validate input type (integer, enum, allowlist) before the query.',
],
@ -878,6 +878,12 @@ export function FindingDetailPage() {
const hasRelated = f.related_findings && f.related_findings.length > 0;
const hasLabels = f.labels && f.labels.length > 0;
const hasCode = !!f.code_context;
const sourcePath = evidence?.source
? `${evidence.source.path}:${evidence.source.line}:${evidence.source.col}`
: null;
const sinkPath = evidence?.sink
? `${evidence.sink.path}:${evidence.sink.line}:${evidence.sink.col}`
: null;
const metaParts: string[] = [];
if (f.category) metaParts.push(f.category);
@ -894,7 +900,7 @@ export function FindingDetailPage() {
}
return (
<div className="detail-panel finding-detail">
<div className="detail-panel finding-detail page-shell">
<div className="detail-title-row">
<h2 className="finding-heading">
<span
@ -931,6 +937,22 @@ export function FindingDetailPage() {
))}
</div>
{(sourcePath || sinkPath) && (
<div className="path-trace" aria-label="Source to sink path">
<div className="path-trace-card">
<span className="path-trace-label">Source</span>
<code className="path-trace-path">{sourcePath || 'Unknown'}</code>
</div>
<div className="path-trace-arrow" aria-hidden>
&rarr;
</div>
<div className="path-trace-card">
<span className="path-trace-label">Sink</span>
<code className="path-trace-path">{sinkPath || 'Unknown'}</code>
</div>
</div>
)}
<StatusControl
finding={f}
onTriage={handleTriage}
@ -974,13 +996,6 @@ export function FindingDetailPage() {
<HowToFix finding={f} />
</CollapsibleSection>
{/* Evidence (collapsed by default — overlaps with taint flow) */}
{hasEvidence && (
<CollapsibleSection title="Evidence" defaultOpen={false}>
<EvidenceSection evidence={evidence!} skipStateCard={isState} />
</CollapsibleSection>
)}
{/* Analysis Notes */}
{hasNotes && (
<CollapsibleSection title="Analysis Notes" defaultOpen={false}>
@ -1002,20 +1017,6 @@ export function FindingDetailPage() {
</CollapsibleSection>
)}
{/* Labels */}
{hasLabels && (
<CollapsibleSection title="Labels" defaultOpen={false}>
<div className="label-list">
{f.labels.map(([k, v], i) => (
<span key={i} className="label-item">
<span className="label-key">{k}:</span>{' '}
<span className="label-value">{v}</span>
</span>
))}
</div>
</CollapsibleSection>
)}
{/* Code Preview */}
{hasCode && (
<CollapsibleSection title="Code Preview" defaultOpen={false}>

View file

@ -567,15 +567,7 @@ export function FindingsPage() {
const totalPages = Math.ceil(data.total / data.per_page) || 1;
return (
<>
<div className="page-header">
<h2>Findings</h2>
<span className="filter-count">
{data.total} finding{data.total !== 1 ? 's' : ''}
{hasActiveFilters ? ' (filtered)' : ''}
</span>
</div>
<div className="findings-page page-shell">
{/* Filter bar */}
<div className="filter-bar">
<input
@ -793,6 +785,6 @@ export function FindingsPage() {
onClose={() => setSuppressModalOpen(false)}
/>
)}
</>
</div>
);
}

View file

@ -64,7 +64,7 @@ export function OverviewPage() {
const categoryItems = (overview.issue_categories || [])
.slice(0, 8)
.map((b) => ({ label: b.label, value: b.count, color: '#72f3d7' }));
.map((b) => ({ label: b.label, value: b.count, color: 'var(--accent)' }));
const trendData = (trends || []).map((t) => ({
label: t.timestamp,
@ -74,11 +74,7 @@ export function OverviewPage() {
const hotSinks = overview.hot_sinks || [];
return (
<>
<div className="page-header">
<h2>Overview</h2>
</div>
<div className="overview-page page-shell">
{/* Baseline strip */}
<BaselinePinControl
baseline={overview.baseline}
@ -117,7 +113,7 @@ export function OverviewPage() {
</div>
)}
{/* Stat cards kept lean: 5 cards, severity stacks live in Top Files
{/* Stat cards kept lean: 5 cards, severity stacks live in Top Files
and Per-Language. Cross-file / Symex moved into Scanner Quality. */}
<div className="overview-stat-grid overview-stat-grid-5">
<StatCard
@ -145,7 +141,7 @@ export function OverviewPage() {
/>
</div>
{/* Charts */}
{/* Charts — 3-col: Findings (col1 span2) | OWASP+Confidence (col2) | Categories (col3 span2) */}
<div className="overview-chart-grid">
<div className="card">
<div className="card-header">Findings Over Time</div>
@ -158,14 +154,8 @@ export function OverviewPage() {
)}
</div>
<div className="card">
<div className="card-header">OWASP Top 10 (2021)</div>
{overview.owasp_buckets && overview.owasp_buckets.length > 0 ? (
<OwaspChart buckets={overview.owasp_buckets} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No OWASP-mapped findings.</p>
</div>
)}
<div className="card-header">Issue Categories</div>
<HorizontalBarChart items={categoryItems} />
</div>
<div className="card">
<div className="card-header">Confidence Distribution</div>
@ -180,8 +170,14 @@ export function OverviewPage() {
)}
</div>
<div className="card">
<div className="card-header">Issue Categories</div>
<HorizontalBarChart items={categoryItems} />
<div className="card-header">OWASP Top 10 (2021)</div>
{overview.owasp_buckets && overview.owasp_buckets.length > 0 ? (
<OwaspChart buckets={overview.owasp_buckets} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No OWASP-mapped findings.</p>
</div>
)}
</div>
</div>
@ -289,7 +285,7 @@ export function OverviewPage() {
)}
</div>
</div>
</>
</div>
);
}

View file

@ -271,20 +271,7 @@ export function RulesPage() {
if (error) return <ErrorState message={error.message} />;
return (
<>
<div className="page-header">
<h2>Rules</h2>
<span
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
marginLeft: 'var(--space-3)',
}}
>
{(rules || []).length} rules
</span>
</div>
<div className="rules-page page-shell">
<div className="rules-layout">
<div className="rules-list-panel">
<div className="rules-filters">
@ -310,14 +297,7 @@ export function RulesPage() {
</option>
))}
</select>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'var(--text-sm)',
}}
>
<label className="rules-custom-toggle">
<input
type="checkbox"
checked={customOnly}
@ -357,6 +337,6 @@ export function RulesPage() {
)}
</div>
</div>
</>
</div>
);
}

View file

@ -221,7 +221,7 @@ function CompareByGroup({
}, [data, groupField]);
return (
<>
<div className="scan-compare-page page-shell">
{groups.map(([key, items]) => {
const counts = { new: 0, fixed: 0, changed: 0, unchanged: 0 };
items.forEach(
@ -267,7 +267,7 @@ function CompareByGroup({
</CollapsibleSection>
);
})}
</>
</div>
);
}
@ -300,16 +300,10 @@ export function ScanComparePage() {
return (
<>
<button
className="btn btn-sm"
style={{ marginBottom: 'var(--space-4)' }}
onClick={() => navigate('/scans')}
>
Back to Scans
</button>
<div className="page-header">
<h2>Scan Comparison</h2>
<div className="page-action-row">
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
</div>
<div className="compare-header">

View file

@ -71,125 +71,127 @@ function SummaryTab({ scan }: { scan: ScanView }) {
</div>
</div>
<div className="card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<div className="scan-summary-grid">
<div className="card scan-detail-card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
)}
</tbody>
</table>
</div>
{timing && total > 0 && (
<div className="card" style={{ marginTop: 'var(--space-4)' }}>
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{timing && total > 0 && (
<div className="card scan-timing-card">
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
</div>
)}
</div>
</>
);
}
@ -212,7 +214,7 @@ function FindingsTab({ scanId }: { scanId: string }) {
}
return (
<>
<div className="scan-detail-page page-shell">
<div className="table-wrap">
<table>
<thead>
@ -266,7 +268,7 @@ function FindingsTab({ scanId }: { scanId: string }) {
>
Showing {data.findings.length} of {data.total} findings
</div>
</>
</div>
);
}
@ -416,31 +418,19 @@ export function ScanDetailPage() {
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
marginBottom: 'var(--space-4)',
}}
>
<div className="page-action-row">
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
{prevScanId && (
<button
className="btn btn-sm"
style={{ marginLeft: 'auto' }}
className="btn btn-sm page-action-push"
onClick={() => navigate(`/scans/compare/${prevScanId}/${id}`)}
>
Compare with Previous
</button>
)}
</div>
<div className="page-header">
<h2>Scan Detail</h2>
<span className={`status-badge ${scan.status}`}>
<span className={`status-badge ${scan.status}`} style={{ marginLeft: 'auto' }}>
<span className={`status-dot ${scan.status}`}></span>
{scan.status}
</span>

View file

@ -174,11 +174,7 @@ export function ScansPage() {
const showCheckboxes = completedScans.length >= 2;
return (
<>
<div className="page-header">
<h2>Scans</h2>
</div>
<div className="scans-page page-shell">
{(runningScans.length > 0 || isScanRunning) && scanProgress && (
<ScanProgress data={scanProgress} />
)}
@ -210,90 +206,103 @@ export function ScansPage() {
</div>
) : (
<div className="table-wrap">
<table>
<table className="scans-table">
<thead>
<tr>
{showCheckboxes && <th style={{ width: 32 }}></th>}
<th>Status</th>
<th>Root</th>
<th>Duration</th>
<th>Findings</th>
<th>Languages</th>
<th>Started</th>
<th style={{ width: 60 }}></th>
{showCheckboxes && <th className="scan-select-col"></th>}
<th className="scan-status-col">Status</th>
<th className="scan-root-col">Root</th>
<th className="scan-duration-col">Duration</th>
<th className="scan-findings-col">Findings</th>
<th className="scan-languages-col">Languages</th>
<th className="scan-started-col">Started</th>
<th className="scan-actions-col"></th>
</tr>
</thead>
<tbody>
{scans.map((s: ScanView) => (
<tr
key={s.id}
className="clickable"
onClick={() => navigate(`/scans/${s.id}`)}
>
{showCheckboxes && (
{scans.map((s: ScanView) => {
const languages = s.languages || [];
const visibleLanguages = languages.slice(0, 4);
const hiddenLanguageCount =
languages.length - visibleLanguages.length;
return (
<tr
key={s.id}
className="clickable"
onClick={() => navigate(`/scans/${s.id}`)}
>
{showCheckboxes && (
<td>
{s.status === 'completed' && (
<input
type="checkbox"
className="scan-compare-cb"
checked={selectedScans.has(s.id)}
onClick={(e) => handleCheckbox(e, s.id)}
onChange={() => {}}
/>
)}
</td>
)}
<td>
{s.status === 'completed' && (
<input
type="checkbox"
className="scan-compare-cb"
checked={selectedScans.has(s.id)}
onClick={(e) => handleCheckbox(e, s.id)}
onChange={() => {}}
/>
<span className={`status-badge ${s.status}`}>
<span className={`status-dot ${s.status}`}></span>
{s.status}
</span>
</td>
<td className="scan-root-cell" title={s.scan_root}>
{truncPath(s.scan_root)}
</td>
<td className="scan-number-cell">
{s.duration_secs != null
? s.duration_secs.toFixed(2) + 's'
: '-'}
</td>
<td className="scan-number-cell">
{s.finding_count ?? '-'}
</td>
<td className="scan-languages-cell">
{languages.length > 0 ? (
<span className="scan-language-list">
{visibleLanguages.map((l) => (
<span key={l} className="lang-badge">
{l}
</span>
))}
{hiddenLanguageCount > 0 && (
<span className="lang-badge lang-badge-more">
+{hiddenLanguageCount}
</span>
)}
</span>
) : (
'-'
)}
</td>
)}
<td>
<span className={`status-badge ${s.status}`}>
<span className={`status-dot ${s.status}`}></span>
{s.status}
</span>
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.82rem',
}}
>
{truncPath(s.scan_root)}
</td>
<td>
{s.duration_secs != null
? s.duration_secs.toFixed(2) + 's'
: '-'}
</td>
<td>{s.finding_count ?? '-'}</td>
<td>
{(s.languages || []).length > 0
? (s.languages || []).map((l) => (
<span key={l} className="lang-badge">
{l}
</span>
))
: '-'}
</td>
<td>{relTime(s.started_at)}</td>
<td>
{s.status !== 'running' && (
<button
className="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this scan?')) {
deleteScan.mutate(s.id);
}
}}
>
Delete
</button>
)}
</td>
</tr>
))}
<td>{relTime(s.started_at)}</td>
<td className="scan-actions-cell">
{s.status !== 'running' && (
<button
className="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this scan?')) {
deleteScan.mutate(s.id);
}
}}
>
Delete
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
</div>
);
}

View file

@ -130,6 +130,22 @@ function TriageSummary({
<div className="triage-hero">
<div className="triage-hero-row">
<h1 className="triage-hero-title">{headline}</h1>
{showSeverity && totalCount > 0 && (
<div className="triage-hero-severity">
{SEVERITIES.map((sev) => (
<span
key={sev}
className={`triage-sev-stat triage-sev-${sev.toLowerCase()}`}
>
<span className="triage-sev-dot" aria-hidden />
<span className="triage-sev-count">
{(openBySev[sev] ?? 0).toLocaleString()}
</span>
<span className="triage-sev-name">{sev}</span>
</span>
))}
</div>
)}
{totalCount > 0 && (
<button
type="button"
@ -142,22 +158,6 @@ function TriageSummary({
</button>
)}
</div>
{showSeverity && totalCount > 0 && (
<div className="triage-hero-severity">
{SEVERITIES.map((sev) => (
<span
key={sev}
className={`triage-sev-stat triage-sev-${sev.toLowerCase()}`}
>
<span className="triage-sev-dot" aria-hidden />
<span className="triage-sev-count">
{(openBySev[sev] ?? 0).toLocaleString()}
</span>
<span className="triage-sev-name">{sev}</span>
</span>
))}
</div>
)}
{expanded && (
<div className="triage-state-row">
<button
@ -1124,7 +1124,7 @@ export function TriagePage() {
GROUP_MODES.find((g) => g.value === groupMode)?.label ?? 'None';
return (
<div className="triage-page">
<div className="triage-page page-shell">
<TriageSummary
totalCount={totalCount}
needsAttention={needsAttention}
@ -1260,7 +1260,7 @@ export function TriagePage() {
<input
className="triage-search"
type="search"
placeholder="Search rule, file, message"
placeholder="Search rule, file, message..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>

View file

@ -85,7 +85,7 @@ export function FunctionSelector({
}
function formatFunctionLabel(fn: FunctionInfo): string {
const sig = `(${fn.param_count} params) L${fn.line}`;
const sig = `(${fn.param_count} params), L${fn.line}`;
if (fn.func_kind === 'closure' && fn.container) {
return `${fn.name} [closure in ${fn.container}] ${sig}`;
}

View file

@ -63,7 +63,7 @@ export function TypeFactsAnalysisPanel({
{securityFacts.length > 0 && (
<TypeFactGroup
title="Security-Relevant Types"
subtitle="HttpClient, DatabaseConnection, Url, … — drive type-qualified callee resolution and sink suppression"
subtitle="HttpClient, DatabaseConnection, Url, and related types drive type-qualified callee resolution and sink suppression"
facts={securityFacts}
highlight
/>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import { getNodeStyle, getEdgeStyle } from '@/graph/styles';
describe('getNodeStyle', () => {
it('returns a style for Entry nodes', () => {
const s = getNodeStyle('Entry');
expect(s.fill).toBe('#2ecc71');
expect(s.fill).toBe('#1c5c38');
expect(s.shape).toBe('double');
});
@ -47,26 +47,26 @@ describe('getNodeStyle', () => {
it('returns a specialized style for recursive call graph nodes', () => {
const s = getNodeStyle('Call', 'callgraph', { isRecursive: true });
expect(s.fill).toBe('#7d6450');
expect(s.fill).toBe('#5a5042');
});
});
describe('getEdgeStyle', () => {
it('returns green color for True edges', () => {
const s = getEdgeStyle('True');
expect(s.color).toBe('#2ecc71');
expect(s.color).toBe('#1c5c38');
expect(s.dash).toEqual([]);
});
it('returns red color for False edges', () => {
const s = getEdgeStyle('False');
expect(s.color).toBe('#e74c3c');
expect(s.color).toBe('#9d2f25');
expect(s.dash).toEqual([]);
});
it('returns dashed style for Back edges', () => {
const s = getEdgeStyle('Back');
expect(s.color).toBe('#4f78c2');
expect(s.color).toBe('#6c6660');
expect(s.dash).toEqual([7, 4]);
});

View file

@ -44,7 +44,7 @@ const full: FindingView = {
language: 'python',
status: 'new',
triage_state: 'investigating',
triage_note: 'Looks real assigned to alice.',
triage_note: 'Looks real - assigned to alice.',
code_context: {
start_line: 138,
highlight_line: 141,
@ -120,7 +120,7 @@ const full: FindingView = {
describe('findingToMarkdown', () => {
it('renders the full finding with all sections', () => {
const md = findingToMarkdown(full);
expect(md).toContain('## py-sqli User input flows into SQL query.');
expect(md).toContain('## py-sqli - User input flows into SQL query.');
expect(md).toContain('- **Rule**: `py-sqli` (category: `sqli`)');
expect(md).toContain('- **Severity**: High | **Confidence**: High');
expect(md).toContain('- **Location**: `src/handlers/login.py:141:10`');
@ -130,7 +130,7 @@ describe('findingToMarkdown', () => {
expect(md).toContain('### Explanation\nUntrusted input reaches');
expect(md).toContain('### Evidence');
expect(md).toContain(
'**Source** `src/handlers/login.py:138:7` (kind: UserInput)',
'**Source**: `src/handlers/login.py:138:7` (kind: UserInput)',
);
expect(md).toContain('```python\nrequest.args.get("name")\n```');
expect(md).toContain('**Guards**: none');
@ -146,19 +146,19 @@ describe('findingToMarkdown', () => {
expect(md).toContain('### Notes');
expect(md).toContain('- Source type: User Input');
expect(md).toContain('- Path length: 3 blocks');
expect(md).toContain('### Triage note\nLooks real assigned to alice.');
expect(md).toContain('### Triage note\nLooks real - assigned to alice.');
expect(md).toContain('### Confidence reasoning');
expect(md).toContain('Score: 8.7');
expect(md).toContain('- **source_kind**: direct user input');
expect(md).toContain('### Related findings');
expect(md).toContain(
'- `#99` `py-xss` `src/handlers/login.py:160` (Medium)',
'- `#99` `py-xss` - `src/handlers/login.py:160` (Medium)',
);
});
it('skips optional sections for a lean finding', () => {
const md = findingToMarkdown(lean);
expect(md).toContain('## js-xss xss');
expect(md).toContain('## js-xss - xss');
expect(md).toContain('**Confidence**: unknown');
expect(md).not.toContain('### Message');
expect(md).not.toContain('### Evidence');

View file

@ -41,10 +41,10 @@ function formatEvidence(ev: Evidence, lang: string | undefined): string {
if (hasSpans) {
const lines: string[] = ['### Evidence'];
if (ev.source) {
lines.push(`**Source** ${formatSpan(ev.source, lang)}`);
lines.push(`**Source**: ${formatSpan(ev.source, lang)}`);
}
if (ev.sink) {
lines.push(`**Sink** ${formatSpan(ev.sink, lang)}`);
lines.push(`**Sink**: ${formatSpan(ev.sink, lang)}`);
}
if (ev.source || ev.sink) {
if (!ev.guards || ev.guards.length === 0) {
@ -68,7 +68,7 @@ function formatEvidence(ev: Evidence, lang: string | undefined): string {
const st = ev.state;
const subj = st.subject ? ` ${st.subject}:` : '';
lines.push(
`**State**: ${st.machine} ${subj} ${st.from_state} ${st.to_state}`,
`**State**: ${st.machine} -${subj} ${st.from_state} -> ${st.to_state}`,
);
}
parts.push(lines.join('\n'));
@ -87,7 +87,7 @@ function formatFlow(steps: FlowStep[]): string {
const lines: string[] = [`### Flow (${steps.length} steps)`];
for (const s of steps) {
const segs: string[] = [`${s.step}. **${s.kind}** \`${s.file}:${s.line}\``];
if (s.snippet) segs.push(` \`${s.snippet}\``);
if (s.snippet) segs.push(`- \`${s.snippet}\``);
if (s.variable) segs.push(`(var \`${s.variable}\`)`);
if (s.callee) segs.push(`(callee \`${s.callee}\`)`);
if (s.is_cross_file) segs.push(`[cross-file]`);
@ -117,7 +117,7 @@ function formatRelated(related: RelatedFindingView[]): string {
const lines: string[] = ['### Related findings'];
for (const r of related) {
lines.push(
`- \`#${r.index}\` \`${r.rule_id}\` \`${r.path}:${r.line}\` (${r.severity})`,
`- \`#${r.index}\` \`${r.rule_id}\` - \`${r.path}:${r.line}\` (${r.severity})`,
);
}
return lines.join('\n');
@ -128,7 +128,7 @@ export function findingToMarkdown(f: FindingView): string {
const heading = firstLine(f.message || '').trim() || f.category;
const parts: string[] = [];
parts.push(`## ${f.rule_id} ${heading}`);
parts.push(`## ${f.rule_id} - ${heading}`);
const meta: string[] = [];
meta.push(`- **Rule**: \`${f.rule_id}\` (category: \`${f.category}\`)`);