From 41bdd2c73a01927c38dafa5c192c0b53175b3713 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 29 Dec 2025 18:56:34 -0500 Subject: [PATCH] Add alert evidence drawer and filters Add a ticker filter bar, alert evidence drawer, and a 30-minute severity strip to flesh out the dashboard panels. --- apps/web/app/globals.css | 273 ++++++++++++++++++++++++ apps/web/app/page.tsx | 434 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 686 insertions(+), 21 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d2b0953..f519407 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -78,6 +78,70 @@ h1 { min-width: 220px; } +.filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 16px 20px; + border-radius: 18px; + border: 1px solid var(--panel-border); + background: rgba(255, 253, 247, 0.9); +} + +.filter-label { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 0.7rem; + color: #6f5b39; +} + +.filter-help { + margin: 0; + color: #4e3e25; + font-size: 0.9rem; +} + +.filter-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-input { + border: 1px solid rgba(111, 91, 57, 0.35); + border-radius: 999px; + padding: 8px 14px; + min-width: 220px; + background: #fffdf7; + font-family: inherit; + font-size: 0.9rem; + color: #1d1d1b; +} + +.filter-input:focus-visible { + outline: 2px solid rgba(47, 109, 79, 0.3); + outline-offset: 2px; +} + +.filter-clear { + border: 1px solid rgba(111, 91, 57, 0.35); + border-radius: 999px; + padding: 6px 12px; + background: rgba(111, 91, 57, 0.08); + color: #6f5b39; + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.filter-clear:disabled { + opacity: 0.5; + cursor: default; +} + .summary-title { font-size: 0.75rem; text-transform: uppercase; @@ -316,6 +380,24 @@ h1 { border: 1px solid rgba(217, 205, 184, 0.6); } +.row-button { + width: 100%; + text-align: left; + cursor: pointer; + font: inherit; + color: inherit; +} + +.row-button:hover { + border-color: rgba(47, 109, 79, 0.4); + box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12); +} + +.row-button:focus-visible { + outline: 2px solid rgba(47, 109, 79, 0.4); + outline-offset: 2px; +} + .contract { font-weight: 600; margin-bottom: 6px; @@ -382,6 +464,176 @@ h1 { color: #5b4c34; } +.drawer { + position: fixed; + top: 88px; + right: 6vw; + width: min(360px, 92vw); + max-height: calc(100vh - 140px); + overflow: auto; + padding: 20px; + border-radius: 20px; + border: 1px solid var(--panel-border); + background: #fffdf7; + box-shadow: 0 32px 60px rgba(66, 45, 18, 0.22); + z-index: 30; +} + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.drawer-eyebrow { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 0.65rem; + color: #6f5b39; +} + +.drawer h3 { + margin: 0 0 4px; + font-size: 1.1rem; +} + +.drawer-subtitle { + margin: 0; + color: #6f5b39; + font-size: 0.8rem; +} + +.drawer-close { + border: 1px solid rgba(111, 91, 57, 0.35); + border-radius: 999px; + padding: 6px 12px; + background: rgba(111, 91, 57, 0.08); + color: #6f5b39; + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.drawer-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.drawer-chip { + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(111, 91, 57, 0.35); + background: rgba(111, 91, 57, 0.08); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.drawer-section { + margin-bottom: 18px; +} + +.drawer-section h4 { + margin: 0 0 10px; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.22em; + color: #6f5b39; +} + +.drawer-list { + display: grid; + gap: 12px; +} + +.drawer-row { + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(217, 205, 184, 0.6); + background: rgba(255, 255, 255, 0.75); +} + +.drawer-row-title { + font-weight: 600; + margin-bottom: 6px; +} + +.drawer-row-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 0.75rem; + color: #5b4c34; +} + +.drawer-note { + margin: 8px 0 0; + font-size: 0.72rem; + color: #5b4c34; +} + +.drawer-empty { + margin: 0; + font-size: 0.78rem; + color: #6f5b39; +} + +.severity-strip { + display: grid; + gap: 8px; + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(217, 205, 184, 0.6); + background: rgba(255, 255, 255, 0.7); +} + +.severity-strip-header { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #6f5b39; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.severity-strip-bar { + display: flex; + height: 26px; + border-radius: 999px; + overflow: hidden; + border: 1px solid rgba(217, 205, 184, 0.6); + background: rgba(111, 91, 57, 0.08); +} + +.severity-segment { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + color: #fffdf7; + letter-spacing: 0.08em; +} + +.severity-strip .severity-high { + background: rgba(196, 111, 42, 0.85); + color: #3b1a09; +} + +.severity-strip .severity-medium { + background: rgba(31, 74, 123, 0.8); +} + +.severity-strip .severity-low { + background: rgba(47, 109, 79, 0.8); +} + .flow-meta span { display: inline-flex; align-items: center; @@ -436,6 +688,21 @@ h1 { padding: 36px 6vw 56px; } + .filter-bar { + flex-direction: column; + align-items: flex-start; + } + + .filter-controls { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .filter-input { + width: 100%; + } + .row { flex-direction: column; align-items: flex-start; @@ -444,6 +711,12 @@ h1 { .time { text-align: left; } + + .drawer { + position: static; + width: 100%; + max-height: none; + } } @media (max-width: 1100px) { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2b39715..41acf38 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -163,6 +163,11 @@ const formatTime = (ts: number): string => { const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`; +const formatDateTime = (ts: number): string => { + const date = new Date(ts); + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; +}; + const humanizeClassifierId = (value: string): string => { if (!value) { return "Classifier"; @@ -182,6 +187,14 @@ const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" => return "neutral"; }; +const extractUnderlying = (contractId: string): string => { + const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/); + if (match?.[1]) { + return match[1].toUpperCase(); + } + return contractId.split("-")[0]?.toUpperCase() ?? contractId.toUpperCase(); +}; + const parseNumber = (value: unknown, fallback: number): number => { if (typeof value === "number" && Number.isFinite(value)) { return value; @@ -809,6 +822,167 @@ const TapeControls = ({ isAtTop, missed, onJump }: TapeControlsProps) => { ); }; +type AlertSeverityStripProps = { + alerts: AlertEvent[]; +}; + +const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { + const windowMs = 30 * 60 * 1000; + const now = Date.now(); + const counts = alerts.reduce( + (acc, alert) => { + if (now - alert.source_ts > windowMs) { + return acc; + } + if (alert.severity === "high") { + acc.high += 1; + } else if (alert.severity === "medium") { + acc.medium += 1; + } else { + acc.low += 1; + } + return acc; + }, + { high: 0, medium: 0, low: 0 } + ); + + const total = counts.high + counts.medium + counts.low; + const highPct = total > 0 ? (counts.high / total) * 100 : 0; + const mediumPct = total > 0 ? (counts.medium / total) * 100 : 0; + const lowPct = total > 0 ? (counts.low / total) * 100 : 0; + + return ( +
+
+ Last 30m + {total} alerts +
+
+
+ {counts.high > 0 ? counts.high : ""} +
+
+ {counts.medium > 0 ? counts.medium : ""} +
+
+ {counts.low > 0 ? counts.low : ""} +
+
+
+ ); +}; + +type EvidenceItem = + | { kind: "flow"; id: string; packet: FlowPacket } + | { kind: "print"; id: string; print: OptionPrint } + | { kind: "unknown"; id: string }; + +type AlertDrawerProps = { + alert: AlertEvent; + flowPacket: FlowPacket | null; + evidence: EvidenceItem[]; + onClose: () => void; +}; + +const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => { + const primary = alert.hits[0]; + const direction = primary ? normalizeDirection(primary.direction) : "neutral"; + const evidencePrints = evidence.filter((item) => item.kind === "print"); + const unknownCount = evidence.filter((item) => item.kind === "unknown").length; + + return ( + + ); +}; + const formatFlowMetric = (value: number, suffix?: string): string => { if (suffix) { return `${value}${suffix}`; @@ -819,6 +993,8 @@ const formatFlowMetric = (value: number, suffix?: string): string => { export default function HomePage() { const [mode, setMode] = useState("live"); + const [selectedAlert, setSelectedAlert] = useState(null); + const [filterInput, setFilterInput] = useState(""); const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); @@ -861,6 +1037,165 @@ export default function HomePage() { classifierScroll.onNewItems ); + const activeTickers = useMemo(() => { + const parts = filterInput + .split(/[,\s]+/) + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); + return Array.from(new Set(parts)); + }, [filterInput]); + + const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); + + const optionPrintMap = useMemo(() => { + const map = new Map(); + for (const print of options.items) { + if (print.trace_id) { + map.set(print.trace_id, print); + } + } + return map; + }, [options.items]); + + const flowPacketMap = useMemo(() => { + const map = new Map(); + for (const packet of flow.items) { + map.set(packet.id, packet); + } + return map; + }, [flow.items]); + + const selectedEvidence = useMemo((): EvidenceItem[] => { + if (!selectedAlert) { + return []; + } + + return selectedAlert.evidence_refs.map((id) => { + const packet = flowPacketMap.get(id); + if (packet) { + return { kind: "flow", id, packet }; + } + const print = optionPrintMap.get(id); + if (print) { + return { kind: "print", id, print }; + } + return { kind: "unknown", id }; + }); + }, [selectedAlert, flowPacketMap, optionPrintMap]); + + const selectedFlowPacket = useMemo(() => { + if (!selectedAlert) { + return null; + } + const packetId = selectedAlert.evidence_refs[0]; + return packetId ? flowPacketMap.get(packetId) ?? null : null; + }, [selectedAlert, flowPacketMap]); + + useEffect(() => { + if (mode !== "live") { + setSelectedAlert(null); + } + }, [mode]); + + const extractPacketContract = useCallback((packet: FlowPacket): string => { + const contract = packet.features.option_contract_id; + if (typeof contract === "string") { + return contract; + } + const match = packet.id.match(/^flowpacket:([^:]+):/); + return match?.[1] ?? packet.id; + }, []); + + const extractUnderlyingFromTrace = useCallback((traceId: string): string | null => { + const match = traceId.match(/flowpacket:([^:]+):/); + if (!match?.[1]) { + return null; + } + return extractUnderlying(match[1]); + }, []); + + const inferAlertUnderlying = useCallback( + (alert: AlertEvent): string | null => { + const fromTrace = extractUnderlyingFromTrace(alert.trace_id); + if (fromTrace) { + return fromTrace; + } + + const packetId = alert.evidence_refs[0]; + if (packetId) { + const packet = flowPacketMap.get(packetId); + if (packet) { + return extractUnderlying(extractPacketContract(packet)); + } + } + + for (const ref of alert.evidence_refs) { + const print = optionPrintMap.get(ref); + if (print) { + return extractUnderlying(print.option_contract_id); + } + } + + return null; + }, + [extractPacketContract, extractUnderlyingFromTrace, flowPacketMap, optionPrintMap] + ); + + const matchesTicker = useCallback( + (value: string | null) => { + if (tickerSet.size === 0) { + return true; + } + if (!value) { + return false; + } + return tickerSet.has(value.toUpperCase()); + }, + [tickerSet] + ); + + const filteredOptions = useMemo(() => { + if (tickerSet.size === 0) { + return options.items; + } + return options.items.filter((print) => + matchesTicker(extractUnderlying(print.option_contract_id)) + ); + }, [options.items, matchesTicker, tickerSet]); + + const filteredEquities = useMemo(() => { + if (tickerSet.size === 0) { + return equities.items; + } + return equities.items.filter((print) => matchesTicker(print.underlying_id)); + }, [equities.items, matchesTicker, tickerSet]); + + const filteredFlow = useMemo(() => { + if (tickerSet.size === 0) { + return flow.items; + } + return flow.items.filter((packet) => + matchesTicker(extractUnderlying(extractPacketContract(packet))) + ); + }, [flow.items, extractPacketContract, matchesTicker, tickerSet]); + + const filteredAlerts = useMemo(() => { + if (tickerSet.size === 0) { + return alerts.items; + } + return alerts.items.filter((alert) => matchesTicker(inferAlertUnderlying(alert))); + }, [alerts.items, inferAlertUnderlying, matchesTicker, tickerSet]); + + const filteredClassifierHits = useMemo(() => { + if (tickerSet.size === 0) { + return classifierHits.items; + } + return classifierHits.items.filter((hit) => { + const underlying = extractUnderlyingFromTrace(hit.trace_id); + return matchesTicker(underlying); + }); + }, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]); + const lastSeen = useMemo(() => { return [ options.lastUpdate, @@ -904,6 +1239,31 @@ export default function HomePage() { +
+
+

Ticker filter

+

+ {activeTickers.length > 0 ? `Filtering ${activeTickers.join(", ")}` : "All tickers"} +

+
+
+ setFilterInput(event.target.value)} + placeholder="SPY, NVDA, AAPL" + /> + +
+
+
@@ -931,14 +1291,16 @@ export default function HomePage() {
- {options.items.length === 0 ? ( + {filteredOptions.length === 0 ? (
- {mode === "live" - ? "No option prints yet. Start ingest-options." - : "Replay queue empty. Ensure ClickHouse has data."} + {tickerSet.size > 0 + ? "No option prints match the current filter." + : mode === "live" + ? "No option prints yet. Start ingest-options." + : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - options.items.map((print) => ( + filteredOptions.map((print) => (
{print.option_contract_id}
@@ -984,14 +1346,16 @@ export default function HomePage() {
- {equities.items.length === 0 ? ( + {filteredEquities.length === 0 ? (
- {mode === "live" - ? "No equity prints yet. Start ingest-equities." - : "Replay queue empty. Ensure ClickHouse has data."} + {tickerSet.size > 0 + ? "No equity prints match the current filter." + : mode === "live" + ? "No equity prints yet. Start ingest-equities." + : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - equities.items.map((print) => ( + filteredEquities.map((print) => (
{print.underlying_id}
@@ -1041,10 +1405,14 @@ export default function HomePage() {
{mode !== "live" ? (
Flow packets are live-only in this build.
- ) : flow.items.length === 0 ? ( -
No flow packets yet. Start compute.
+ ) : filteredFlow.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No flow packets match the current filter." + : "No flow packets yet. Start compute."} +
) : ( - flow.items.map((packet) => { + filteredFlow.map((packet) => { const features = packet.features ?? {}; const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); const count = parseNumber(features.count, packet.members.length); @@ -1102,18 +1470,29 @@ export default function HomePage() { />
+ +
{mode !== "live" ? (
Alerts are live-only in this build.
- ) : alerts.items.length === 0 ? ( -
No alerts yet. Start compute.
+ ) : filteredAlerts.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No alerts match the current filter." + : "No alerts yet. Start compute."} +
) : ( - alerts.items.map((alert) => { + filteredAlerts.map((alert) => { const primary = alert.hits[0]; const direction = primary ? normalizeDirection(primary.direction) : "neutral"; return ( -
+ ); }) )} @@ -1166,10 +1545,14 @@ export default function HomePage() {
{mode !== "live" ? (
Classifier hits are live-only in this build.
- ) : classifierHits.items.length === 0 ? ( -
No classifier hits yet. Start compute.
+ ) : filteredClassifierHits.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No classifier hits match the current filter." + : "No classifier hits yet. Start compute."} +
) : ( - classifierHits.items.map((hit) => { + filteredClassifierHits.map((hit) => { const direction = normalizeDirection(hit.direction); return (
@@ -1191,6 +1574,15 @@ export default function HomePage() {
+ + {selectedAlert ? ( + setSelectedAlert(null)} + /> + ) : null} ); }