Refine signals layout and alert labeling

- Rework the Signals grid to prioritize alerts and move dark flow below
- Normalize alert severity/direction labels and tighten feed status copy
- Add helper tests for severity, direction, anchoring, and status text
This commit is contained in:
dirtydishes 2026-04-29 01:28:44 -04:00
parent d3ff19b5c0
commit 5cdf4a43e0
4 changed files with 191 additions and 32 deletions

View file

@ -594,8 +594,8 @@ h3 {
} }
.page-grid-signals { .page-grid-signals {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 1fr);
height: calc(100vh - var(--topbar-height) - 172px); height: calc(100vh - var(--topbar-height) - 172px);
min-height: 620px; min-height: 620px;
} }
@ -659,7 +659,7 @@ h3 {
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.terminal-pane-body, .terminal-pane-body,
@ -774,15 +774,21 @@ h3 {
.tape-controls { .tape-controls {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: nowrap;
}
.tape-controls button {
white-space: nowrap;
} }
.missed-count { .missed-count {
min-width: 62px; width: 86px;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--accent); color: var(--accent);
text-align: right; text-align: right;
white-space: nowrap;
} }
.list { .list {
@ -815,6 +821,21 @@ h3 {
min-height: 0; min-height: 0;
} }
.page-grid-signals > .signals-pane-alerts {
grid-column: 1;
grid-row: 1;
}
.page-grid-signals > .signals-pane-rules {
grid-column: 2;
grid-row: 1;
}
.page-grid-signals > .signals-pane-dark {
grid-column: 1 / -1;
grid-row: 2;
}
.page-grid-tape > :first-child { .page-grid-tape > :first-child {
height: clamp(460px, 64vh, 880px); height: clamp(460px, 64vh, 880px);
} }

View file

@ -1,14 +1,18 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
buildDefaultFlowFilters, buildDefaultFlowFilters,
deriveAlertDirection,
countActiveFlowFilterGroups, countActiveFlowFilterGroups,
flushPausableTapeData, flushPausableTapeData,
getAlertWindowAnchorTs,
getLiveFeedStatus, getLiveFeedStatus,
normalizeAlertSeverity,
nextFlowFilterPopoverState, nextFlowFilterPopoverState,
projectPausableTapeState, projectPausableTapeState,
reducePausableTapeData, reducePausableTapeData,
shouldRetainLiveSnapshotHistory, shouldRetainLiveSnapshotHistory,
shouldShowEquitiesSilentFeedWarning, shouldShowEquitiesSilentFeedWarning,
statusLabel,
toggleFilterValue toggleFilterValue
} from "./terminal"; } from "./terminal";
@ -18,6 +22,17 @@ const makeItem = (traceId: string, seq: number, ts: number) => ({
ts ts
}); });
const makeAlert = (overrides: Record<string, unknown> = {}) =>
({
trace_id: "alert-1",
seq: 1,
source_ts: 1_000,
severity: "low",
score: 20,
hits: [],
...overrides
}) as any;
describe("live tape pausable helpers", () => { describe("live tape pausable helpers", () => {
it("queues new items while paused and flushes them on resume", () => { it("queues new items while paused and flushes them on resume", () => {
let state = reducePausableTapeData( let state = reducePausableTapeData(
@ -128,3 +143,58 @@ describe("flow filter popup helpers", () => {
expect(buildDefaultFlowFilters()).toEqual(defaults); expect(buildDefaultFlowFilters()).toEqual(defaults);
}); });
}); });
describe("signals helpers", () => {
it("normalizes severity aliases/casing and falls back to score", () => {
expect(normalizeAlertSeverity(makeAlert({ severity: "HIGH", score: 1 }))).toBe("high");
expect(normalizeAlertSeverity(makeAlert({ severity: "med", score: 1 }))).toBe("medium");
expect(normalizeAlertSeverity(makeAlert({ severity: "informational", score: 99 }))).toBe("low");
expect(normalizeAlertSeverity(makeAlert({ severity: "unknown", score: 80 }))).toBe("high");
expect(normalizeAlertSeverity(makeAlert({ severity: "unknown", score: 45 }))).toBe("medium");
expect(normalizeAlertSeverity(makeAlert({ severity: "unknown", score: 44 }))).toBe("low");
});
it("derives dominant direction with confidence tie-break and neutral fallback", () => {
expect(
deriveAlertDirection(
makeAlert({
hits: [
{ direction: "bullish", confidence: 0.4 },
{ direction: "bullish", confidence: 0.2 },
{ direction: "bearish", confidence: 0.9 }
]
})
)
).toBe("bullish");
expect(
deriveAlertDirection(
makeAlert({
hits: [
{ direction: "bullish", confidence: 0.4 },
{ direction: "bearish", confidence: 0.9 }
]
})
)
).toBe("bearish");
expect(deriveAlertDirection(makeAlert({ hits: [{ direction: "weird", confidence: 0.4 }] }))).toBe(
"neutral"
);
expect(deriveAlertDirection(makeAlert({ hits: [] }))).toBe("neutral");
});
it("anchors strip window to latest visible alert timestamp", () => {
const alerts = [
makeAlert({ source_ts: 1_700_000_000_000, severity: "high" }),
makeAlert({ source_ts: 1_700_000_000_000 - 10 * 60 * 1000, severity: "low" })
];
expect(getAlertWindowAnchorTs(alerts, 42)).toBe(1_700_000_000_000);
expect(getAlertWindowAnchorTs([], 42)).toBe(42);
});
it("returns connected/stale live status labels without live wording", () => {
expect(statusLabel("connected", false, "live")).toBe("Connected");
expect(statusLabel("stale", false, "live")).toBe("Feed behind");
});
});

View file

@ -582,6 +582,66 @@ const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" =>
return "neutral"; return "neutral";
}; };
const normalizeAlertSeverityValue = (value: string): "high" | "medium" | "low" | null => {
const normalized = value.trim().toLowerCase();
if (["high", "critical", "severe", "sev1", "p0", "p1"].includes(normalized)) {
return "high";
}
if (["medium", "med", "moderate", "sev2", "p2"].includes(normalized)) {
return "medium";
}
if (["low", "minor", "info", "informational", "sev3", "p3", "p4"].includes(normalized)) {
return "low";
}
return null;
};
export const normalizeAlertSeverity = (alert: AlertEvent): "high" | "medium" | "low" => {
const normalized = normalizeAlertSeverityValue(alert.severity);
if (normalized) {
return normalized;
}
if (alert.score >= 80) {
return "high";
}
if (alert.score >= 45) {
return "medium";
}
return "low";
};
export const deriveAlertDirection = (alert: AlertEvent): "bullish" | "bearish" | "neutral" => {
const totals = {
bullish: { count: 0, confidence: 0 },
bearish: { count: 0, confidence: 0 },
neutral: { count: 0, confidence: 0 }
};
for (const hit of alert.hits) {
const direction = normalizeDirection(hit.direction);
totals[direction].count += 1;
totals[direction].confidence += Number.isFinite(hit.confidence) ? hit.confidence : 0;
}
const ranked = (Object.entries(totals) as Array<
["bullish" | "bearish" | "neutral", { count: number; confidence: number }]
>).sort((a, b) => {
if (b[1].count !== a[1].count) {
return b[1].count - a[1].count;
}
return b[1].confidence - a[1].confidence;
});
return ranked[0] && ranked[0][1].count > 0 ? ranked[0][0] : "neutral";
};
export const getAlertWindowAnchorTs = (alerts: AlertEvent[], fallbackNow = Date.now()): number => {
if (alerts.length === 0) {
return fallbackNow;
}
return alerts.reduce((max, alert) => Math.max(max, alert.source_ts), alerts[0]?.source_ts ?? fallbackNow);
};
const extractUnderlying = (contractId: string): string => { const extractUnderlying = (contractId: string): string => {
const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/); const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/);
if (match?.[1]) { if (match?.[1]) {
@ -1142,7 +1202,7 @@ const prunePinnedEntries = <T,>(
return new Map(trimmed); return new Map(trimmed);
}; };
const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => { export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => {
if (paused) { if (paused) {
return "Paused"; return "Paused";
} }
@ -1153,9 +1213,9 @@ const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string
switch (status) { switch (status) {
case "connected": case "connected":
return "Live"; return "Connected";
case "stale": case "stale":
return "Live feed behind"; return "Feed behind";
case "connecting": case "connecting":
return "Connecting"; return "Connecting";
case "disconnected": case "disconnected":
@ -3020,15 +3080,16 @@ type AlertSeverityStripProps = {
const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => {
const windowMs = 30 * 60 * 1000; const windowMs = 30 * 60 * 1000;
const now = Date.now(); const windowAnchor = getAlertWindowAnchorTs(alerts);
const severityCounts = alerts.reduce( const severityCounts = alerts.reduce(
(acc, alert) => { (acc, alert) => {
if (now - alert.source_ts > windowMs) { if (windowAnchor - alert.source_ts > windowMs) {
return acc; return acc;
} }
if (alert.severity === "high") { const severity = normalizeAlertSeverity(alert);
if (severity === "high") {
acc.high += 1; acc.high += 1;
} else if (alert.severity === "medium") { } else if (severity === "medium") {
acc.medium += 1; acc.medium += 1;
} else { } else {
acc.low += 1; acc.low += 1;
@ -3040,10 +3101,10 @@ const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => {
const directionCounts = alerts.reduce( const directionCounts = alerts.reduce(
(acc, alert) => { (acc, alert) => {
if (now - alert.source_ts > windowMs) { if (windowAnchor - alert.source_ts > windowMs) {
return acc; return acc;
} }
const direction = normalizeDirection(alert.hits[0]?.direction ?? "neutral"); const direction = deriveAlertDirection(alert);
acc[direction] += 1; acc[direction] += 1;
return acc; return acc;
}, },
@ -3119,7 +3180,8 @@ type AlertDrawerProps = {
const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => { const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => {
const primary = alert.hits[0]; const primary = alert.hits[0];
const direction = primary ? normalizeDirection(primary.direction) : "neutral"; const direction = deriveAlertDirection(alert);
const severity = normalizeAlertSeverity(alert);
const evidencePrints = evidence.filter((item) => item.kind === "print"); const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
@ -3137,9 +3199,9 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
</div> </div>
<div className="drawer-meta"> <div className="drawer-meta">
<span className={`pill severity-${alert.severity}`}>{alert.severity}</span> <span className={`pill severity-${severity}`}>{severity}</span>
<span className="drawer-chip">Score {Math.round(alert.score)}</span> <span className="drawer-chip">Score {Math.round(alert.score)}</span>
{primary ? <span className={`pill direction-${direction}`}>{direction}</span> : null} <span className={`pill direction-${direction}`}>{direction}</span>
</div> </div>
<div className="drawer-section"> <div className="drawer-section">
@ -4875,7 +4937,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
? "No option prints match the current filter." ? "No option prints match the current filter."
: state.mode === "live" : state.mode === "live"
? state.options.status === "stale" ? state.options.status === "stale"
? "Live feed behind. Waiting for fresh option prints." ? "Feed behind. Waiting for fresh option prints."
: "No option prints yet. Start ingest-options." : "No option prints yet. Start ingest-options."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
@ -5000,8 +5062,8 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
: state.mode === "live" : state.mode === "live"
? state.equitiesSilentWarning ? state.equitiesSilentWarning
? "Connected but no equity prints received. Check ingest-equities." ? "Connected but no equity prints received. Check ingest-equities."
: state.equities.status === "stale" : state.equities.status === "stale"
? "Live feed behind. Waiting for fresh equity prints." ? "Feed behind. Waiting for fresh equity prints."
: "No equity prints yet. Start ingest-equities." : "No equity prints yet. Start ingest-equities."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
@ -5079,7 +5141,7 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => {
? "No flow packets match the current filter." ? "No flow packets match the current filter."
: state.mode === "live" : state.mode === "live"
? state.flow.status === "stale" ? state.flow.status === "stale"
? "Live feed behind. Waiting for fresh flow packets." ? "Feed behind. Waiting for fresh flow packets."
: "No flow packets yet. Start compute." : "No flow packets yet. Start compute."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
@ -5180,15 +5242,17 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => {
type AlertsPaneProps = { type AlertsPaneProps = {
limit?: number; limit?: number;
withStrip?: boolean; withStrip?: boolean;
className?: string;
}; };
const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => { const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => {
const state = useTerminal(); const state = useTerminal();
const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts; const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts;
const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 92); const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 92);
return ( return (
<Pane <Pane
className={className}
title="Alerts" title="Alerts"
status={ status={
<TapeStatus <TapeStatus
@ -5228,7 +5292,8 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
) : null} ) : null}
{virtual.visibleItems.map((alert) => { {virtual.visibleItems.map((alert) => {
const primary = alert.hits[0]; const primary = alert.hits[0];
const direction = primary ? normalizeDirection(primary.direction) : "neutral"; const direction = deriveAlertDirection(alert);
const severity = normalizeAlertSeverity(alert);
return ( return (
<button <button
@ -5246,12 +5311,10 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"} {primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}
</div> </div>
<div className="meta"> <div className="meta">
<span className={`pill severity-${alert.severity}`}>{alert.severity}</span> <span className={`pill severity-${severity}`}>{severity}</span>
<span>Score {Math.round(alert.score)}</span> <span>Score {Math.round(alert.score)}</span>
<span>{alert.hits.length} hits</span> <span>{alert.hits.length} hits</span>
{primary ? ( <span className={`pill direction-${direction}`}>{direction}</span>
<span className={`pill direction-${direction}`}>{direction}</span>
) : null}
</div> </div>
{primary?.explanations?.[0] ? ( {primary?.explanations?.[0] ? (
<div className="note">{primary.explanations[0]}</div> <div className="note">{primary.explanations[0]}</div>
@ -5273,15 +5336,17 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
type ClassifierPaneProps = { type ClassifierPaneProps = {
limit?: number; limit?: number;
className?: string;
}; };
const ClassifierPane = ({ limit }: ClassifierPaneProps) => { const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => {
const state = useTerminal(); const state = useTerminal();
const items = limit ? state.filteredClassifierHits.slice(0, limit) : state.filteredClassifierHits; const items = limit ? state.filteredClassifierHits.slice(0, limit) : state.filteredClassifierHits;
const virtual = useVirtualList(items, state.classifierScroll.listRef, !limit, 88); const virtual = useVirtualList(items, state.classifierScroll.listRef, !limit, 88);
return ( return (
<Pane <Pane
className={className}
title="Rules" title="Rules"
status={ status={
<TapeStatus <TapeStatus
@ -5351,15 +5416,17 @@ const ClassifierPane = ({ limit }: ClassifierPaneProps) => {
type DarkPaneProps = { type DarkPaneProps = {
limit?: number; limit?: number;
className?: string;
}; };
const DarkPane = ({ limit }: DarkPaneProps) => { const DarkPane = ({ limit, className }: DarkPaneProps) => {
const state = useTerminal(); const state = useTerminal();
const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark; const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark;
const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 88); const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 88);
return ( return (
<Pane <Pane
className={className}
title="Dark" title="Dark"
status={ status={
<TapeStatus <TapeStatus
@ -5713,9 +5780,9 @@ export function SignalsRoute() {
return ( return (
<PageFrame title="Signals"> <PageFrame title="Signals">
<div className="page-grid page-grid-signals"> <div className="page-grid page-grid-signals">
<AlertsPane withStrip /> <AlertsPane withStrip className="signals-pane-alerts" />
<ClassifierPane /> <ClassifierPane className="signals-pane-rules" />
<DarkPane /> <DarkPane className="signals-pane-dark" />
</div> </div>
</PageFrame> </PageFrame>
); );

File diff suppressed because one or more lines are too long