OpenVuln / src /components /PlatformPulse.tsx
zRzRzRzRzRzRzR
feat: restore minimal Docker Space
2eb87e3
Raw
History Blame Contribute Delete
11.3 kB
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { OverviewStats, TrendDay } from "@/shared/types";
import { SEV_ORDER, severityLabel } from "../shared/lib/format";
const SEV = {
high: "#F24F4F",
medium: "#FF733C",
low: "#F7C530",
critical: "#C22828",
} as const;
type Pt = TrendDay;
function stackedAreas(data: Pt[], w = 560, h = 180) {
const keys = ["critical", "high", "medium", "low"] as const;
const max = Math.max(...data.map((d) => (d.critical??0)+d.high+d.medium+d.low ), 1);
const x = (i: number) => (data.length <= 1 ? 0 : (i / (data.length - 1)) * w);
const y = (v: number) => h - (v / max) * (h - 16);
const acc = data.map(() => 0);
return keys.map((k) => {
const top = data.map((d, i) => {
acc[i] += d[k];
return [x(i), y(acc[i])] as const;
});
const base = data.map((d, i) => [x(i), y(acc[i] - d[k])] as const).reverse();
const line = top
.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`)
.join("");
const area = `${line}${base.map((p) => `L${p[0].toFixed(1)},${p[1].toFixed(1)}`).join("")}Z`;
return { key: k, area, line };
});
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const fn = () => setReduced(mq.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return reduced;
}
function formatElapsed(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
const PANEL = "rounded-lg border border-line bg-surface-raised p-4";
const EYEBROW = "font-mono text-[11px] uppercase tracking-wider text-ink-tertiary";
/** 首页第二页(浅色):趋势图 / LIVE / 新增漏洞事件卡 / TOP CWE */
export function PlatformPulse({ stats }: { stats: OverviewStats | undefined }) {
const reduced = usePrefersReducedMotion();
const trend = stats?.trend ?? [];
const layers = useMemo(() => (trend.length ? stackedAreas(trend) : []), [trend]);
const pathRefs = useRef<(SVGPathElement | null)[]>([]);
useEffect(() => {
if (reduced) return;
for (const el of pathRefs.current) {
if (!el) continue;
const len = el.getTotalLength();
el.style.strokeDasharray = `${len}`;
el.style.strokeDashoffset = `${len}`;
el.getBoundingClientRect();
el.style.transition = "stroke-dashoffset 900ms ease-out";
el.style.strokeDashoffset = "0";
}
}, [layers, reduced]);
const sevTotals = stats?.severity_counts ?? { critical: 0, high: 0, medium: 0, low: 0 };
const live = stats?.live;
const scanningCount = live?.scanning.length ?? 0;
const queuedCount = live?.queued_count ?? 0;
const cweTop = stats?.cwe_top ?? [];
const cweMax = Math.max(1, ...cweTop.map((c) => c.count));
const hasFindings =
(stats?.finding_total ?? 0) > 0 || trend.some((d) => (d.critical??0)+d.high+d.medium+d.low > 0);
const xTicks = useMemo(() => {
if (trend.length < 2) return [];
const idxs = [0, 7, 14, 21, 29].filter((i) => i < trend.length);
return idxs.map((i) => ({
i,
label: trend[i].date.slice(5),
x: (i / (trend.length - 1)) * 560,
}));
}, [trend]);
return (
<div className="mx-auto max-w-6xl px-6 py-6">
<p className={`mb-4 ${EYEBROW}`}>
Platform Pulse
<span className="mx-2 text-line">·</span>
<span className="inline-flex items-center gap-1.5 text-success-ink">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
Live
</span>
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Findings 趋势 */}
<div className={`${PANEL} lg:col-span-7`}>
<div className="flex items-baseline justify-between gap-2">
<p className={EYEBROW}>Findings · last 30 days</p>
<p className="font-display text-sm font-semibold text-ink">
{(stats?.finding_total ?? 0).toLocaleString()}
</p>
</div>
<div className="relative mt-3">
{!hasFindings && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<span className="text-sm text-ink-tertiary">Awaiting first scans</span>
</div>
)}
<svg
viewBox="0 0 560 180"
className="h-auto w-full"
role="img"
aria-label="Stacked area chart of findings by severity over the last 30 days"
>
{[0.25, 0.5, 0.75].map((g) => (
<line
key={g}
x1={0}
x2={560}
y1={16 + (180 - 16) * (1 - g)}
y2={16 + (180 - 16) * (1 - g)}
stroke="#E7E8EB"
strokeWidth={1}
/>
))}
{[...layers].reverse().map((layer) => (
<path
key={`a-${layer.key}`}
d={layer.area}
fill={SEV[layer.key]}
fillOpacity={reduced ? 0.25 : 0}
style={
reduced
? undefined
: { animation: "pulse-fade-in 400ms ease-out forwards", animationDelay: "200ms" }
}
/>
))}
{layers.map((layer, idx) => (
<path
key={`l-${layer.key}`}
ref={(el) => {
pathRefs.current[idx] = el;
}}
d={layer.line}
fill="none"
stroke={SEV[layer.key]}
strokeWidth={1.5}
/>
))}
{xTicks.map((t) => (
<text
key={t.i}
x={t.x}
y={176}
textAnchor={t.i === 0 ? "start" : t.i >= 28 ? "end" : "middle"}
className="fill-ink-tertiary"
style={{ fontSize: 10, fontFamily: "ui-monospace, monospace" }}
>
{t.label}
</text>
))}
</svg>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[12px] text-ink-secondary">
{SEV_ORDER.map((s) => (
<span key={s} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: SEV[s] }}
/>
{severityLabel(s)} {sevTotals[s]}
</span>
))}
</div>
</div>
{/* LIVE NOW */}
<div className={`${PANEL} lg:col-span-5`}>
<p className={EYEBROW}>Live now</p>
<p className="mt-2 flex items-center gap-2 text-sm text-ink">
<span className="relative flex h-2 w-2">
{(scanningCount > 0 || queuedCount > 0) && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
scanningCount > 0 ? "bg-success" : "bg-ink-tertiary"
}`}
/>
</span>
<span className="font-mono text-[13px]">
{scanningCount > 0 || queuedCount > 0 ? (
<>
{scanningCount} scanning · {queuedCount} queued
</>
) : (
<span className="text-ink-tertiary">Idle · queue empty</span>
)}
</span>
</p>
<ul className="mt-3 space-y-2.5">
{(live?.scanning ?? []).map((s) => (
<li key={s.project_id} className="flex items-center gap-2 text-sm">
<LiveDot />
<Link
to={`/p/${s.full_name}`}
className="min-w-0 flex-1 truncate text-ink hover:text-accent-600"
>
{s.full_name}
</Link>
<span className="shrink-0 font-mono text-[12px] text-ink-tertiary">
{formatElapsed(s.elapsed_sec)}
</span>
</li>
))}
</ul>
</div>
{/* TOP CWE */}
<div className={`${PANEL} lg:col-span-12`}>
<p className={EYEBROW}>Top CWE</p>
{cweTop.length === 0 ? (
<p className="mt-3 text-sm text-ink-tertiary">No CWE data yet.</p>
) : (
<ul className="mt-3 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2 lg:grid-cols-4">
{cweTop.map((c) => (
<li key={c.cwe} className="flex items-center gap-2 text-sm">
<a
href={
c.cwe.startsWith("CWE-")
? `https://cwe.mitre.org/data/definitions/${c.cwe.replace("CWE-", "")}.html`
: undefined
}
target="_blank"
rel="noreferrer"
className="w-[4.5rem] shrink-0 font-mono text-[12px] text-accent-600 hover:underline"
>
{c.cwe}
</a>
<span className="w-16 shrink-0 truncate text-[12px] text-ink-secondary">
{c.name ?? "—"}
</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-accent-600"
style={{ width: `${(c.count / cweMax) * 100}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-[12px] text-ink-secondary">
{c.count}
</span>
</li>
))}
</ul>
)}
</div>
</div>
<style>{`
@keyframes pulse-fade-in {
from { fill-opacity: 0; }
to { fill-opacity: 0.25; }
}
@keyframes live-ring {
0% { transform: scale(0.6); opacity: 0.7; }
100% { transform: scale(2.2); opacity: 0; }
}
`}</style>
</div>
);
}
function LiveDot() {
return (
<span className="relative flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<span
className="absolute h-2.5 w-2.5 rounded-full bg-success/40 motion-reduce:hidden"
style={{ animation: "live-ring 1.6s ease-out infinite" }}
/>
<span className="relative h-1.5 w-1.5 rounded-full bg-success" />
</span>
);
}