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:
parent
d3ff19b5c0
commit
5cdf4a43e0
4 changed files with 191 additions and 32 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = <T,>(
|
|||
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)
|
|||
</div>
|
||||
|
||||
<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>
|
||||
{primary ? <span className={`pill direction-${direction}`}>{direction}</span> : null}
|
||||
<span className={`pill direction-${direction}`}>{direction}</span>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
|
|
@ -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."}
|
||||
</div>
|
||||
|
|
@ -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."}
|
||||
</div>
|
||||
|
|
@ -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."}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<Pane
|
||||
className={className}
|
||||
title="Alerts"
|
||||
status={
|
||||
<TapeStatus
|
||||
|
|
@ -5228,7 +5292,8 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
|
|||
) : 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 (
|
||||
<button
|
||||
|
|
@ -5246,12 +5311,10 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
|
|||
{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}
|
||||
</div>
|
||||
<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>{alert.hits.length} hits</span>
|
||||
{primary ? (
|
||||
<span className={`pill direction-${direction}`}>{direction}</span>
|
||||
) : null}
|
||||
<span className={`pill direction-${direction}`}>{direction}</span>
|
||||
</div>
|
||||
{primary?.explanations?.[0] ? (
|
||||
<div className="note">{primary.explanations[0]}</div>
|
||||
|
|
@ -5273,15 +5336,17 @@ const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => {
|
|||
|
||||
type ClassifierPaneProps = {
|
||||
limit?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ClassifierPane = ({ limit }: ClassifierPaneProps) => {
|
||||
const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => {
|
||||
const state = useTerminal();
|
||||
const items = limit ? state.filteredClassifierHits.slice(0, limit) : state.filteredClassifierHits;
|
||||
const virtual = useVirtualList(items, state.classifierScroll.listRef, !limit, 88);
|
||||
|
||||
return (
|
||||
<Pane
|
||||
className={className}
|
||||
title="Rules"
|
||||
status={
|
||||
<TapeStatus
|
||||
|
|
@ -5351,15 +5416,17 @@ const ClassifierPane = ({ limit }: ClassifierPaneProps) => {
|
|||
|
||||
type DarkPaneProps = {
|
||||
limit?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DarkPane = ({ limit }: DarkPaneProps) => {
|
||||
const DarkPane = ({ limit, className }: DarkPaneProps) => {
|
||||
const state = useTerminal();
|
||||
const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark;
|
||||
const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 88);
|
||||
|
||||
return (
|
||||
<Pane
|
||||
className={className}
|
||||
title="Dark"
|
||||
status={
|
||||
<TapeStatus
|
||||
|
|
@ -5713,9 +5780,9 @@ export function SignalsRoute() {
|
|||
return (
|
||||
<PageFrame title="Signals">
|
||||
<div className="page-grid page-grid-signals">
|
||||
<AlertsPane withStrip />
|
||||
<ClassifierPane />
|
||||
<DarkPane />
|
||||
<AlertsPane withStrip className="signals-pane-alerts" />
|
||||
<ClassifierPane className="signals-pane-rules" />
|
||||
<DarkPane className="signals-pane-dark" />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
|
|
|
|||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue