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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 432 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 21 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 13 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Before After
Before After

View file

@ -3,8 +3,16 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nyx Scanner</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Nyx</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon-64.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

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}\`)`);

View file

@ -53,11 +53,12 @@
* docs/serve-rules.png
* docs/serve-config.png
*/
import { execFileSync } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
unlinkSync,
writeFileSync,
@ -73,15 +74,16 @@ const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/ny
// Sibling marketing site that mirrors a small subset of these assets.
// Set NYXSCAN_DIR=skip to disable the mirror step.
const NYXSCAN_DIR = process.env.NYXSCAN_DIR || '/Users/elipeter/nyxscan.dev/assets/screenshots';
const VIEW = { width: 1440, height: 900 };
const VIEW = { width: 1280, height: 960 };
const COLOR_SCHEME = 'light';
const args = new Set(process.argv.slice(2));
const wantStills = args.has('--stills') || args.has('--all');
const wantGif = args.has('--gif') || args.has('--all');
const wantCli = args.has('--cli') || args.has('--all');
if (!wantStills && !wantGif && !wantCli) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--all]');
const wantCombo = args.has('--combo') || args.has('--all');
if (!wantStills && !wantGif && !wantCli && !wantCombo) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--combo|--all]');
process.exit(2);
}
@ -337,9 +339,9 @@ async function captureGifFrames(page) {
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(800);
@ -394,18 +396,61 @@ async function captureGifFrames(page) {
await sleep(1500);
}
// Combo GIF browser storyboard — data already present from VHS scan phase -----
async function captureGifFramesCombo(page) {
console.error('[combo/gif] scene 1: overview with scan data');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 15_000 })
.catch(() => {});
await sleep(2200);
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(900);
console.error('[combo/gif] scene 2: findings list');
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
await page.waitForURL('**/findings', { timeout: 10_000 });
await page.waitForSelector('tbody tr', { timeout: 10_000 });
await sleep(1500);
console.error('[combo/gif] scene 3: 5-hop taint finding detail');
const taintRow = await findFirstTaintRow(page);
await taintRow.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2500);
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1600);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1600);
console.error('[combo/gif] scene 4: open Evidence + Analysis Notes');
for (const title of ['Evidence', 'Analysis Notes']) {
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
if (await toggle.count()) {
await toggle.scrollIntoViewIfNeeded();
await sleep(500);
await toggle.click();
await sleep(1000);
}
}
await sleep(1200);
}
async function convertWebmToGif(webm, gifOut) {
const palette = '/tmp/nyx-demo-palette.png';
console.error('[gif] generating palette');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm,
'-vf', 'fps=15,scale=1440:-1:flags=lanczos,palettegen',
'-vf', 'fps=15,scale=1280:-1:flags=lanczos,palettegen',
palette,
], { stdio: 'inherit' });
console.error('[gif] palette → gif');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
'-lavfi', 'fps=15,scale=1440:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
'-lavfi', 'fps=15,scale=1280:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
gifOut,
], { stdio: 'inherit' });
}
@ -538,6 +583,143 @@ function captureCli() {
// without a much larger fixture; the existing image is left alone.
}
// Combo GIF ------------------------------------------------------------------
// Single GIF: CLI scan (VHS terminal) → hard cut → serve UI (Playwright).
// The VHS portion is a visual recording only — nyx scan (standalone CLI)
// writes to a separate store that nyx serve does not read. After VHS we
// wipe state and trigger a real scan through the serve API so Playwright
// has live data to explore.
async function captureComboGif() {
function wipeState() {
rmSync(join(SCAN_ROOT, '.nyx'), { recursive: true, force: true });
const homeDir = process.env.HOME || '/Users/elipeter';
const sysDbBase = join(homeDir, 'Library/Application Support/nyx/nyx-demo-app.sqlite');
for (const suffix of ['', '-wal', '-shm']) {
try { unlinkSync(sysDbBase + suffix); } catch {}
}
}
// 1. Clean state + write demo.
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
await sleep(800);
wipeState();
writeDemo('v1');
// 2. VHS: scan → results pause → type nyx serve → see it start.
const cliGifPath = '/tmp/nyx-combo-cli.gif';
const tapePath = '/tmp/nyx-combo.tape';
const tape = [
`Output "${cliGifPath}"`,
'',
'Set Shell "bash"',
'Set FontSize 22',
'Set Width 1280',
'Set Height 960',
'Set Framerate 15',
'Env CLICOLOR_FORCE "1"',
'',
'Sleep 500ms',
`Type "${NYX_BIN} scan ${SCAN_ROOT}"`,
'Sleep 300ms',
'Enter',
'Sleep 1500ms',
`Type "${NYX_BIN} serve --port 9876 --no-browser ${SCAN_ROOT}"`,
'Sleep 300ms',
'Enter',
'Sleep 2000ms',
].join('\n');
writeFileSync(tapePath, tape);
console.error('[combo] recording CLI portion with vhs');
execFileSync(VHS_BIN, [tapePath], { stdio: 'inherit' });
// 3. Wipe state again and start a fresh host serve. The VHS scan wrote
// to standalone storage that nyx serve doesn't read, so we drive a
// real scan through the serve API to populate the browser session.
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
await sleep(800);
wipeState();
const serveProc = spawn(NYX_BIN, [
'serve', '--port', '9876', '--no-browser', SCAN_ROOT,
], { detached: false, stdio: 'ignore' });
serveProc.unref();
await waitForServer();
const comboToken = await csrfToken();
const comboBefore = await currentScanId();
await startScanViaApi(comboToken);
await waitForScanComplete(comboBefore);
// 4. Playwright: record browser walkthrough against the live scan data.
const videoDir = '/tmp/nyx-combo-video';
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
mkdirSync(videoDir, { recursive: true });
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: true });
try {
const ctx = await browser.newContext({
viewport: VIEW,
colorScheme: COLOR_SCHEME,
recordVideo: { dir: videoDir, size: VIEW },
});
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
await captureGifFramesCombo(page);
await page.close();
await ctx.close();
} finally {
await browser.close();
}
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
// 5. Find Playwright webm.
const webms = readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
if (!webms.length) throw new Error('[combo] no webm captured for browser portion');
const webmPath = join(videoDir, webms[0]);
// 6. ffmpeg: three-step to avoid OOM from single-pass concat+palettegen.
// Step A: concat VHS gif + browser webm → intermediate webm.
// Step B: generate global palette from intermediate.
// Step C: palette → final GIF.
const comboOut = join(OUT_DIR, 'demo-combo.gif');
const comboIntermediate = '/tmp/nyx-combo-intermediate.mp4';
const comboPalette = '/tmp/nyx-combo-palette.png';
console.error('[combo] step A: concat → intermediate webm');
execFileSync('ffmpeg', [
'-y',
'-ignore_loop', '1', '-r', '15', '-i', cliGifPath,
'-ss', '1.0', '-r', '15', '-i', webmPath,
'-filter_complex',
'[0:v]scale=1280:960:flags=lanczos,fps=15[cli];' +
'[1:v]scale=1280:960:flags=lanczos,fps=15[bro];' +
'[cli][bro]concat=n=2:v=1:a=0[out]',
'-map', '[out]',
'-c:v', 'libx264', '-crf', '28', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
comboIntermediate,
], { stdio: 'inherit' });
console.error('[combo] step B: generate palette');
execFileSync('ffmpeg', [
'-y', '-i', comboIntermediate,
'-vf', 'fps=15,palettegen',
'-update', '1', '-frames:v', '1',
comboPalette,
], { stdio: 'inherit' });
console.error('[combo] step C: palette → gif');
execFileSync('ffmpeg', [
'-y', '-i', comboIntermediate, '-i', comboPalette,
'-lavfi', 'fps=15 [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
comboOut,
], { stdio: 'inherit' });
console.error(`[combo] wrote ${comboOut}`);
}
// Frame phase ----------------------------------------------------------------
const STILLS_PNGS = [
@ -604,6 +786,7 @@ const NYXSCAN_MIRROR = [
['docs/serve-overview_raw.png', 'overview.png'],
['docs/serve-finding-detail_raw.png', 'finding-detail.png'],
['cli-scan_raw.gif', 'cli-scan.gif'],
['demo-combo.gif', 'demo-combo.gif'],
];
function syncNyxscanDev() {
if (NYXSCAN_DIR === 'skip') return;
@ -711,7 +894,11 @@ async function main() {
captureCli();
}
if (wantStills || wantCli || wantGif) {
if (wantCombo) {
await captureComboGif();
}
if (wantStills || wantCli || wantGif || wantCombo) {
// Frame phase — only frame what was captured this run so that
// already-framed PNGs from prior runs aren't framed again.
// Stills and the GIF use the fixed 1600x992 inner; CLI captures

View file

@ -6,6 +6,10 @@ static INDEX_HTML: &str = include_str!("assets/dist/index.html");
static STYLE_CSS: &str = include_str!("assets/dist/style.css");
static APP_JS: &str = include_str!("assets/dist/app.js");
static FAVICON_SVG: &str = include_str!("assets/favicon.svg");
static FAVICON_32: &[u8] = include_bytes!("assets/favicon-32.png");
static FAVICON_64: &[u8] = include_bytes!("assets/favicon-64.png");
static FAVICON_180: &[u8] = include_bytes!("assets/favicon-180.png");
static LOGO_PNG: &[u8] = include_bytes!("assets/logo.png");
/// Serve embedded static files or fall back to the SPA shell.
pub async fn static_handler(req: Request) -> Response {
@ -33,6 +37,30 @@ pub async fn static_handler(req: Request) -> Response {
FAVICON_SVG,
)
.into_response(),
"/favicon-32.png" => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
FAVICON_32,
)
.into_response(),
"/favicon-64.png" => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
FAVICON_64,
)
.into_response(),
"/favicon-180.png" => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
FAVICON_180,
)
.into_response(),
"/logo.png" => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
LOGO_PNG,
)
.into_response(),
// SPA fallback: any non-API path serves index.html.
_ => Html(INDEX_HTML).into_response(),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src/server/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB