nyx/frontend/src/pages/OverviewPage.tsx

447 lines
13 KiB
TypeScript

import { useNavigate } from 'react-router-dom';
import { useOverview, useOverviewTrends } from '../api/queries/overview';
import { usePinBaseline, useUnpinBaseline } from '../api/mutations/baseline';
import { StatCard } from '../components/ui/StatCard';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { HorizontalBarChart } from '../components/charts/HorizontalBarChart';
import { LineChart } from '../components/charts/LineChart';
import { OverviewIcon } from '../components/icons/Icons';
import { truncPath } from '../utils/truncPath';
import {
HealthScoreCard,
BacklogCard,
ConfidenceDistributionChart,
ScannerQualityPanel,
HotSinksList,
OwaspChart,
WeightedTopFiles,
LanguageHealthTable,
SuppressionHygieneCard,
BaselinePinControl,
} from '../components/overview/OverviewWidgets';
import type { OverviewCount, ScanSummary, Insight } from '../api/types';
import { usePageTitle } from '../hooks/usePageTitle';
export function OverviewPage() {
usePageTitle('Overview');
const navigate = useNavigate();
const { data: overview, isLoading, error, refetch } = useOverview();
const { data: trends } = useOverviewTrends();
const pinBaseline = usePinBaseline();
const unpinBaseline = useUnpinBaseline();
if (isLoading) {
return <LoadingState message="Loading overview..." />;
}
if (error) {
return (
<ErrorState
title="Error loading overview"
error={error}
onRetry={() => refetch()}
/>
);
}
if (!overview) {
return <LoadingState message="Loading overview..." />;
}
// Empty state
if (overview.state === 'empty') {
return (
<div className="overview-empty">
<OverviewIcon size={48} />
<h2>Welcome to Nyx</h2>
<p>Run your first scan to see security findings and analytics.</p>
</div>
);
}
const netDelta = overview.new_since_last - overview.fixed_since_last;
const categoryItems = (overview.issue_categories || [])
.slice(0, 8)
.map((b) => ({ label: b.label, value: b.count, color: 'var(--accent)' }));
const trendData = (trends || []).map((t) => ({
label: t.timestamp,
value: t.total,
}));
const hotSinks = overview.hot_sinks || [];
return (
<div className="overview-page page-shell">
{/* Baseline strip */}
<BaselinePinControl
baseline={overview.baseline}
latestScanId={overview.latest_scan_id}
onPin={(id) => pinBaseline.mutate(id)}
onUnpin={() => unpinBaseline.mutate()}
isPending={pinBaseline.isPending || unpinBaseline.isPending}
/>
{overview.health && (
<HealthScoreCard health={overview.health} posture={overview.posture} />
)}
{/* Fresh banner */}
{overview.state === 'fresh' && (
<div className="overview-fresh-banner">
<strong>Scan completed</strong>
<span>
{overview.total_findings} finding
{overview.total_findings === 1 ? '' : 's'} detected
{overview.latest_scan_duration_secs != null
? ` in ${overview.latest_scan_duration_secs.toFixed(1)}s`
: ''}
.
</span>
<a
href="/findings"
className="nav-link-internal"
onClick={(e) => {
e.preventDefault();
navigate('/findings');
}}
>
View all findings &rarr;
</a>
</div>
)}
{/* 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
label="Total Findings"
value={overview.total_findings}
delta={netDelta || null}
/>
<StatCard
label="New"
value={overview.new_since_last}
color={overview.new_since_last > 0 ? 'var(--sev-high)' : undefined}
/>
<StatCard
label="Fixed"
value={overview.fixed_since_last}
color={overview.fixed_since_last > 0 ? 'var(--success)' : undefined}
/>
<StatCard
label="High Confidence"
value={`${(overview.high_confidence_rate * 100).toFixed(0)}%`}
/>
<StatCard
label="Triage Coverage"
value={`${(overview.triage_coverage * 100).toFixed(0)}%`}
/>
</div>
{/* 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>
{trendData.length >= 2 ? (
<LineChart points={trendData} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>Run a second scan to see trends.</p>
</div>
)}
</div>
<div className="card">
<div className="card-header">Issue Categories</div>
<HorizontalBarChart items={categoryItems} />
</div>
<div className="card">
<div className="card-header">Confidence Distribution</div>
{overview.confidence_distribution ? (
<ConfidenceDistributionChart
dist={overview.confidence_distribution}
/>
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No data</p>
</div>
)}
</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>
</div>
{/* Per-language + Top Files */}
<div className="overview-table-grid">
<div className="card">
<div className="card-header">Per-Language Posture</div>
<LanguageHealthTable rows={overview.language_health || []} />
</div>
<div className="card">
<div className="card-header">
Top Affected Files (severity-weighted)
</div>
<WeightedTopFiles
files={overview.weighted_top_files || []}
onRowClick={(name) =>
navigate(`/findings?search=${encodeURIComponent(name)}`)
}
/>
</div>
</div>
{/* Top Rules + Top Directories (or Hot Sinks when taint findings exist) */}
<div className="overview-table-grid">
<div className="card">
<div className="card-header">Top Rules Triggered</div>
<CompactTable
items={overview.top_rules}
nameLabel="Rule"
countLabel="Findings"
/>
</div>
<div className="card">
<div className="card-header">Top Directories</div>
<CompactTable
items={overview.top_directories}
nameLabel="Directory"
countLabel="Findings"
truncate
/>
</div>
</div>
{hotSinks.length > 0 && (
<div className="overview-table-grid">
<div className="card card-full">
<div className="card-header">Hot Sinks (taint flow)</div>
<HotSinksList sinks={hotSinks} />
</div>
</div>
)}
{overview.backlog && <BacklogCard backlog={overview.backlog} />}
{/* Scanner Quality + Hygiene */}
<div className="overview-table-grid">
<div className="card">
<div className="card-header">Scanner Quality</div>
{overview.scanner_quality ? (
<ScannerQualityPanel
quality={overview.scanner_quality}
crossFileRatio={overview.cross_file_ratio}
/>
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No engine metrics available</p>
</div>
)}
</div>
<div className="card">
<div className="card-header">Suppression Hygiene</div>
{overview.suppression_hygiene ? (
<SuppressionHygieneCard hygiene={overview.suppression_hygiene} />
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>No suppressions</p>
</div>
)}
</div>
</div>
{/* Recent scans */}
<div className="overview-table-grid">
<div className="card">
<div className="card-header">Recent Scans</div>
<RecentScansTable
scans={overview.recent_scans}
currentBaselineId={overview.baseline?.scan_id}
onRowClick={(scan) => navigate(`/scans/${scan.id}`)}
onPinBaseline={(scanId) => pinBaseline.mutate(scanId)}
/>
</div>
<div className="card">
<div className="card-header">Insights</div>
{overview.insights.length > 0 ? (
<div className="insight-list">
{overview.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
) : (
<div className="empty-state" style={{ padding: 16 }}>
<p>Nothing to flag.</p>
</div>
)}
</div>
</div>
</div>
);
}
// ── Sub-components ──────────────────────────────────────────────────────────
interface CompactTableProps {
items: OverviewCount[];
nameLabel: string;
countLabel: string;
truncate?: boolean;
onRowClick?: (item: OverviewCount) => void;
}
function CompactTable({
items,
nameLabel,
countLabel,
truncate,
onRowClick,
}: CompactTableProps) {
if (!items || items.length === 0) {
return (
<div className="empty-state" style={{ padding: 16 }}>
<p>No data</p>
</div>
);
}
return (
<table>
<thead>
<tr>
<th>{nameLabel}</th>
<th>{countLabel}</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const displayName = truncate ? truncPath(item.name, 45) : item.name;
return (
<tr
key={item.name}
className={onRowClick ? 'clickable' : undefined}
onClick={onRowClick ? () => onRowClick(item) : undefined}
title={item.name}
>
<td>{displayName}</td>
<td>{item.count}</td>
</tr>
);
})}
</tbody>
</table>
);
}
interface RecentScansTableProps {
scans: ScanSummary[];
currentBaselineId?: string;
onRowClick: (scan: ScanSummary) => void;
onPinBaseline?: (scanId: string) => void;
}
function RecentScansTable({
scans,
currentBaselineId,
onRowClick,
onPinBaseline,
}: RecentScansTableProps) {
if (!scans || scans.length === 0) {
return (
<div className="empty-state" style={{ padding: 16 }}>
<p>No scans yet</p>
</div>
);
}
return (
<table>
<thead>
<tr>
<th>Status</th>
<th>Duration</th>
<th>Findings</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
{scans.slice(0, 5).map((scan) => {
const isBaseline = scan.id === currentBaselineId;
const canPin =
!isBaseline && onPinBaseline && scan.status === 'completed';
return (
<tr
key={scan.id}
className="clickable"
onClick={() => onRowClick(scan)}
>
<td>
<span className={`status-dot ${scan.status}`} /> {scan.status}
</td>
<td>
{scan.duration_secs != null
? `${scan.duration_secs.toFixed(1)}s`
: '-'}
</td>
<td>{scan.finding_count ?? '-'}</td>
<td>
{scan.started_at
? new Date(scan.started_at).toLocaleString()
: '-'}
</td>
<td onClick={(e) => e.stopPropagation()}>
{isBaseline ? (
<span className="baseline-label">baseline</span>
) : canPin ? (
<button
type="button"
className="baseline-action"
onClick={() => onPinBaseline!(scan.id)}
>
Pin
</button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
interface InsightCardProps {
insight: Insight;
}
function InsightCard({ insight }: InsightCardProps) {
const navigate = useNavigate();
return (
<div className={`insight-card insight-${insight.severity}`}>
<span>{insight.message}</span>
{insight.action_url && (
<a
href={insight.action_url}
className="nav-link-internal"
onClick={(e) => {
e.preventDefault();
navigate(insight.action_url!);
}}
>
View &rarr;
</a>
)}
</div>
);
}