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.
This commit is contained in:
dirtydishes 2025-12-29 18:56:34 -05:00
parent 18366192b2
commit 41bdd2c73a
2 changed files with 686 additions and 21 deletions

View file

@ -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) {

View file

@ -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 (
<div className="severity-strip">
<div className="severity-strip-header">
<span>Last 30m</span>
<span>{total} alerts</span>
</div>
<div className="severity-strip-bar">
<div className="severity-segment severity-high" style={{ width: `${highPct}%` }}>
{counts.high > 0 ? counts.high : ""}
</div>
<div className="severity-segment severity-medium" style={{ width: `${mediumPct}%` }}>
{counts.medium > 0 ? counts.medium : ""}
</div>
<div className="severity-segment severity-low" style={{ width: `${lowPct}%` }}>
{counts.low > 0 ? counts.low : ""}
</div>
</div>
</div>
);
};
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 (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Alert details</p>
<h3>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
Close
</button>
</div>
<div className="drawer-meta">
<span className={`pill severity-${alert.severity}`}>{alert.severity}</span>
<span className="drawer-chip">Score {Math.round(alert.score)}</span>
{primary ? <span className={`pill direction-${direction}`}>{direction}</span> : null}
</div>
<div className="drawer-section">
<h4>Classifier hits</h4>
{alert.hits.length === 0 ? (
<p className="drawer-empty">No classifier hits captured.</p>
) : (
<div className="drawer-list">
{alert.hits.map((hit, index) => (
<div className="drawer-row" key={`${alert.trace_id}-${hit.classifier_id}-${index}`}>
<div className="drawer-row-title">{humanizeClassifierId(hit.classifier_id)}</div>
<div className="drawer-row-meta">
<span className={`pill direction-${normalizeDirection(hit.direction)}`}>
{normalizeDirection(hit.direction)}
</span>
<span>Confidence {formatConfidence(hit.confidence)}</span>
</div>
{hit.explanations?.[0] ? (
<p className="drawer-note">{hit.explanations[0]}</p>
) : null}
</div>
))}
</div>
)}
</div>
<div className="drawer-section">
<h4>Flow packet</h4>
{flowPacket ? (
<div className="drawer-row">
<div className="drawer-row-title">
{String(flowPacket.features.option_contract_id ?? flowPacket.id ?? "Flow packet")}
</div>
<div className="drawer-row-meta">
<span>{formatFlowMetric(parseNumber(flowPacket.features.count, flowPacket.members.length))} prints</span>
<span>{formatFlowMetric(parseNumber(flowPacket.features.total_size, 0))} size</span>
<span>${formatPrice(parseNumber(flowPacket.features.total_premium, 0))}</span>
</div>
<p className="drawer-note">
Window {formatFlowMetric(parseNumber(flowPacket.features.window_ms, 0), "ms")} ·{" "}
{formatTime(parseNumber(flowPacket.features.start_ts, flowPacket.source_ts))} {" "}
{formatTime(parseNumber(flowPacket.features.end_ts, flowPacket.source_ts))}
</p>
</div>
) : (
<p className="drawer-empty">Flow packet not in the current live cache.</p>
)}
</div>
<div className="drawer-section">
<h4>Evidence prints</h4>
{evidencePrints.length === 0 ? (
<p className="drawer-empty">No evidence prints in the live cache yet.</p>
) : (
<div className="drawer-list">
{evidencePrints.slice(0, 6).map((item) => (
<div className="drawer-row" key={item.id}>
<div className="drawer-row-title">{item.print.option_contract_id}</div>
<div className="drawer-row-meta">
<span>${formatPrice(item.print.price)}</span>
<span>{formatSize(item.print.size)}x</span>
<span>{item.print.exchange}</span>
</div>
<p className="drawer-note">{formatTime(item.print.ts)}</p>
</div>
))}
</div>
)}
{unknownCount > 0 ? (
<p className="drawer-empty">+{unknownCount} evidence prints not in cache.</p>
) : null}
</div>
</aside>
);
};
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<TapeMode>("live");
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
const [filterInput, setFilterInput] = useState<string>("");
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<string, OptionPrint>();
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<string, FlowPacket>();
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() {
</div>
</header>
<div className="filter-bar">
<div>
<p className="filter-label">Ticker filter</p>
<p className="filter-help">
{activeTickers.length > 0 ? `Filtering ${activeTickers.join(", ")}` : "All tickers"}
</p>
</div>
<div className="filter-controls">
<input
className="filter-input"
value={filterInput}
onChange={(event) => setFilterInput(event.target.value)}
placeholder="SPY, NVDA, AAPL"
/>
<button
className="filter-clear"
type="button"
onClick={() => setFilterInput("")}
disabled={filterInput.trim().length === 0}
>
Clear
</button>
</div>
</div>
<div className="cards">
<section className="card card-options">
<div className="card-header">
@ -931,14 +1291,16 @@ export default function HomePage() {
</div>
<div className="list" ref={optionsScroll.listRef}>
{options.items.length === 0 ? (
{filteredOptions.length === 0 ? (
<div className="empty">
{mode === "live"
{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."}
</div>
) : (
options.items.map((print) => (
filteredOptions.map((print) => (
<div className="row" key={`${print.trace_id}-${print.seq}`}>
<div>
<div className="contract">{print.option_contract_id}</div>
@ -984,14 +1346,16 @@ export default function HomePage() {
</div>
<div className="list" ref={equitiesScroll.listRef}>
{equities.items.length === 0 ? (
{filteredEquities.length === 0 ? (
<div className="empty">
{mode === "live"
{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."}
</div>
) : (
equities.items.map((print) => (
filteredEquities.map((print) => (
<div className="row" key={`${print.trace_id}-${print.seq}`}>
<div>
<div className="contract">{print.underlying_id}</div>
@ -1041,10 +1405,14 @@ export default function HomePage() {
<div className="list" ref={flowScroll.listRef}>
{mode !== "live" ? (
<div className="empty">Flow packets are live-only in this build.</div>
) : flow.items.length === 0 ? (
<div className="empty">No flow packets yet. Start compute.</div>
) : filteredFlow.length === 0 ? (
<div className="empty">
{tickerSet.size > 0
? "No flow packets match the current filter."
: "No flow packets yet. Start compute."}
</div>
) : (
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() {
/>
</div>
<AlertSeverityStrip alerts={filteredAlerts} />
<div className="list" ref={alertsScroll.listRef}>
{mode !== "live" ? (
<div className="empty">Alerts are live-only in this build.</div>
) : alerts.items.length === 0 ? (
<div className="empty">No alerts yet. Start compute.</div>
) : filteredAlerts.length === 0 ? (
<div className="empty">
{tickerSet.size > 0
? "No alerts match the current filter."
: "No alerts yet. Start compute."}
</div>
) : (
alerts.items.map((alert) => {
filteredAlerts.map((alert) => {
const primary = alert.hits[0];
const direction = primary ? normalizeDirection(primary.direction) : "neutral";
return (
<div className="row" key={`${alert.trace_id}-${alert.seq}`}>
<button
className="row row-button"
key={`${alert.trace_id}-${alert.seq}`}
type="button"
onClick={() => setSelectedAlert(alert)}
>
<div>
<div className="contract">
{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}
@ -1131,7 +1510,7 @@ export default function HomePage() {
) : null}
</div>
<div className="time">{formatTime(alert.source_ts)}</div>
</div>
</button>
);
})
)}
@ -1166,10 +1545,14 @@ export default function HomePage() {
<div className="list" ref={classifierScroll.listRef}>
{mode !== "live" ? (
<div className="empty">Classifier hits are live-only in this build.</div>
) : classifierHits.items.length === 0 ? (
<div className="empty">No classifier hits yet. Start compute.</div>
) : filteredClassifierHits.length === 0 ? (
<div className="empty">
{tickerSet.size > 0
? "No classifier hits match the current filter."
: "No classifier hits yet. Start compute."}
</div>
) : (
classifierHits.items.map((hit) => {
filteredClassifierHits.map((hit) => {
const direction = normalizeDirection(hit.direction);
return (
<div className="row" key={`${hit.trace_id}-${hit.seq}`}>
@ -1191,6 +1574,15 @@ export default function HomePage() {
</div>
</section>
</div>
{selectedAlert ? (
<AlertDrawer
alert={selectedAlert}
flowPacket={selectedFlowPacket}
evidence={selectedEvidence}
onClose={() => setSelectedAlert(null)}
/>
) : null}
</main>
);
}