Spaces:
Running
Running
| 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> | |
| ); | |
| } | |