diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0910153..66f6f8d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -594,8 +594,8 @@ h3 { } .page-grid-signals { - grid-template-columns: repeat(3, minmax(0, 1fr)); - grid-template-rows: minmax(0, 1fr); + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: minmax(0, 2fr) minmax(0, 1fr); height: calc(100vh - var(--topbar-height) - 172px); min-height: 620px; } @@ -659,7 +659,7 @@ h3 { align-items: center; justify-content: flex-end; gap: 8px; - flex-wrap: wrap; + flex-wrap: nowrap; } .terminal-pane-body, @@ -774,15 +774,21 @@ h3 { .tape-controls { display: flex; align-items: center; + justify-content: flex-end; gap: 6px; - flex-wrap: wrap; + flex-wrap: nowrap; +} + +.tape-controls button { + white-space: nowrap; } .missed-count { - min-width: 62px; + width: 86px; font-size: 0.72rem; color: var(--accent); text-align: right; + white-space: nowrap; } .list { @@ -815,6 +821,21 @@ h3 { 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 { height: clamp(460px, 64vh, 880px); } diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 8d78abd..8b136e3 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from "bun:test"; import { buildDefaultFlowFilters, + deriveAlertDirection, countActiveFlowFilterGroups, flushPausableTapeData, + getAlertWindowAnchorTs, getLiveFeedStatus, + normalizeAlertSeverity, nextFlowFilterPopoverState, projectPausableTapeState, reducePausableTapeData, shouldRetainLiveSnapshotHistory, shouldShowEquitiesSilentFeedWarning, + statusLabel, toggleFilterValue } from "./terminal"; @@ -18,6 +22,17 @@ const makeItem = (traceId: string, seq: number, ts: number) => ({ ts }); +const makeAlert = (overrides: Record = {}) => + ({ + trace_id: "alert-1", + seq: 1, + source_ts: 1_000, + severity: "low", + score: 20, + hits: [], + ...overrides + }) as any; + describe("live tape pausable helpers", () => { it("queues new items while paused and flushes them on resume", () => { let state = reducePausableTapeData( @@ -128,3 +143,58 @@ describe("flow filter popup helpers", () => { 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"); + }); +}); diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 15bdbd8..a736262 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -582,6 +582,66 @@ const normalizeDirection = (value: string): "bullish" | "bearish" | "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 match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/); if (match?.[1]) { @@ -1142,7 +1202,7 @@ const prunePinnedEntries = ( 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) { return "Paused"; } @@ -1153,9 +1213,9 @@ const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string switch (status) { case "connected": - return "Live"; + return "Connected"; case "stale": - return "Live feed behind"; + return "Feed behind"; case "connecting": return "Connecting"; case "disconnected": @@ -3020,15 +3080,16 @@ type AlertSeverityStripProps = { const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { const windowMs = 30 * 60 * 1000; - const now = Date.now(); + const windowAnchor = getAlertWindowAnchorTs(alerts); const severityCounts = alerts.reduce( (acc, alert) => { - if (now - alert.source_ts > windowMs) { + if (windowAnchor - alert.source_ts > windowMs) { return acc; } - if (alert.severity === "high") { + const severity = normalizeAlertSeverity(alert); + if (severity === "high") { acc.high += 1; - } else if (alert.severity === "medium") { + } else if (severity === "medium") { acc.medium += 1; } else { acc.low += 1; @@ -3040,10 +3101,10 @@ const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { const directionCounts = alerts.reduce( (acc, alert) => { - if (now - alert.source_ts > windowMs) { + if (windowAnchor - alert.source_ts > windowMs) { return acc; } - const direction = normalizeDirection(alert.hits[0]?.direction ?? "neutral"); + const direction = deriveAlertDirection(alert); acc[direction] += 1; return acc; }, @@ -3119,7 +3180,8 @@ type AlertDrawerProps = { const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => { 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 unknownCount = evidence.filter((item) => item.kind === "unknown").length; @@ -3137,9 +3199,9 @@ const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps)
- {alert.severity} + {severity} Score {Math.round(alert.score)} - {primary ? {direction} : null} + {direction}
@@ -4875,7 +4937,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { ? "No option prints match the current filter." : state.mode === "live" ? 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." : "Replay queue empty. Ensure ClickHouse has data."}
@@ -5000,8 +5062,8 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => { : state.mode === "live" ? state.equitiesSilentWarning ? "Connected but no equity prints received. Check ingest-equities." - : state.equities.status === "stale" - ? "Live feed behind. Waiting for fresh equity prints." + : state.equities.status === "stale" + ? "Feed behind. Waiting for fresh equity prints." : "No equity prints yet. Start ingest-equities." : "Replay queue empty. Ensure ClickHouse has data."} @@ -5079,7 +5141,7 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { ? "No flow packets match the current filter." : state.mode === "live" ? 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." : "Replay queue empty. Ensure ClickHouse has data."} @@ -5180,15 +5242,17 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { type AlertsPaneProps = { limit?: number; withStrip?: boolean; + className?: string; }; -const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => { +const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts; const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 92); return ( { ) : null} {virtual.visibleItems.map((alert) => { const primary = alert.hits[0]; - const direction = primary ? normalizeDirection(primary.direction) : "neutral"; + const direction = deriveAlertDirection(alert); + const severity = normalizeAlertSeverity(alert); return (