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:
parent
18366192b2
commit
41bdd2c73a
2 changed files with 686 additions and 21 deletions
|
|
@ -78,6 +78,70 @@ h1 {
|
||||||
min-width: 220px;
|
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 {
|
.summary-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -316,6 +380,24 @@ h1 {
|
||||||
border: 1px solid rgba(217, 205, 184, 0.6);
|
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 {
|
.contract {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
@ -382,6 +464,176 @@ h1 {
|
||||||
color: #5b4c34;
|
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 {
|
.flow-meta span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -436,6 +688,21 @@ h1 {
|
||||||
padding: 36px 6vw 56px;
|
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 {
|
.row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -444,6 +711,12 @@ h1 {
|
||||||
.time {
|
.time {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,11 @@ const formatTime = (ts: number): string => {
|
||||||
|
|
||||||
const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`;
|
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 => {
|
const humanizeClassifierId = (value: string): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "Classifier";
|
return "Classifier";
|
||||||
|
|
@ -182,6 +187,14 @@ const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" =>
|
||||||
return "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 => {
|
const parseNumber = (value: unknown, fallback: number): number => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return 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 => {
|
const formatFlowMetric = (value: number, suffix?: string): string => {
|
||||||
if (suffix) {
|
if (suffix) {
|
||||||
return `${value}${suffix}`;
|
return `${value}${suffix}`;
|
||||||
|
|
@ -819,6 +993,8 @@ const formatFlowMetric = (value: number, suffix?: string): string => {
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [mode, setMode] = useState<TapeMode>("live");
|
const [mode, setMode] = useState<TapeMode>("live");
|
||||||
|
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
|
||||||
|
const [filterInput, setFilterInput] = useState<string>("");
|
||||||
const optionsScroll = useListScroll();
|
const optionsScroll = useListScroll();
|
||||||
const equitiesScroll = useListScroll();
|
const equitiesScroll = useListScroll();
|
||||||
const flowScroll = useListScroll();
|
const flowScroll = useListScroll();
|
||||||
|
|
@ -861,6 +1037,165 @@ export default function HomePage() {
|
||||||
classifierScroll.onNewItems
|
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(() => {
|
const lastSeen = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
options.lastUpdate,
|
options.lastUpdate,
|
||||||
|
|
@ -904,6 +1239,31 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="cards">
|
||||||
<section className="card card-options">
|
<section className="card card-options">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|
@ -931,14 +1291,16 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list" ref={optionsScroll.listRef}>
|
<div className="list" ref={optionsScroll.listRef}>
|
||||||
{options.items.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
{mode === "live"
|
{tickerSet.size > 0
|
||||||
|
? "No option prints match the current filter."
|
||||||
|
: mode === "live"
|
||||||
? "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>
|
||||||
) : (
|
) : (
|
||||||
options.items.map((print) => (
|
filteredOptions.map((print) => (
|
||||||
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
||||||
<div>
|
<div>
|
||||||
<div className="contract">{print.option_contract_id}</div>
|
<div className="contract">{print.option_contract_id}</div>
|
||||||
|
|
@ -984,14 +1346,16 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list" ref={equitiesScroll.listRef}>
|
<div className="list" ref={equitiesScroll.listRef}>
|
||||||
{equities.items.length === 0 ? (
|
{filteredEquities.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
{mode === "live"
|
{tickerSet.size > 0
|
||||||
|
? "No equity prints match the current filter."
|
||||||
|
: mode === "live"
|
||||||
? "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>
|
||||||
) : (
|
) : (
|
||||||
equities.items.map((print) => (
|
filteredEquities.map((print) => (
|
||||||
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
||||||
<div>
|
<div>
|
||||||
<div className="contract">{print.underlying_id}</div>
|
<div className="contract">{print.underlying_id}</div>
|
||||||
|
|
@ -1041,10 +1405,14 @@ export default function HomePage() {
|
||||||
<div className="list" ref={flowScroll.listRef}>
|
<div className="list" ref={flowScroll.listRef}>
|
||||||
{mode !== "live" ? (
|
{mode !== "live" ? (
|
||||||
<div className="empty">Flow packets are live-only in this build.</div>
|
<div className="empty">Flow packets are live-only in this build.</div>
|
||||||
) : flow.items.length === 0 ? (
|
) : filteredFlow.length === 0 ? (
|
||||||
<div className="empty">No flow packets yet. Start compute.</div>
|
<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 features = packet.features ?? {};
|
||||||
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
||||||
const count = parseNumber(features.count, packet.members.length);
|
const count = parseNumber(features.count, packet.members.length);
|
||||||
|
|
@ -1102,18 +1470,29 @@ export default function HomePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertSeverityStrip alerts={filteredAlerts} />
|
||||||
|
|
||||||
<div className="list" ref={alertsScroll.listRef}>
|
<div className="list" ref={alertsScroll.listRef}>
|
||||||
{mode !== "live" ? (
|
{mode !== "live" ? (
|
||||||
<div className="empty">Alerts are live-only in this build.</div>
|
<div className="empty">Alerts are live-only in this build.</div>
|
||||||
) : alerts.items.length === 0 ? (
|
) : filteredAlerts.length === 0 ? (
|
||||||
<div className="empty">No alerts yet. Start compute.</div>
|
<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 primary = alert.hits[0];
|
||||||
const direction = primary ? normalizeDirection(primary.direction) : "neutral";
|
const direction = primary ? normalizeDirection(primary.direction) : "neutral";
|
||||||
|
|
||||||
return (
|
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>
|
||||||
<div className="contract">
|
<div className="contract">
|
||||||
{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}
|
{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}
|
||||||
|
|
@ -1131,7 +1510,7 @@ export default function HomePage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="time">{formatTime(alert.source_ts)}</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}>
|
<div className="list" ref={classifierScroll.listRef}>
|
||||||
{mode !== "live" ? (
|
{mode !== "live" ? (
|
||||||
<div className="empty">Classifier hits are live-only in this build.</div>
|
<div className="empty">Classifier hits are live-only in this build.</div>
|
||||||
) : classifierHits.items.length === 0 ? (
|
) : filteredClassifierHits.length === 0 ? (
|
||||||
<div className="empty">No classifier hits yet. Start compute.</div>
|
<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);
|
const direction = normalizeDirection(hit.direction);
|
||||||
return (
|
return (
|
||||||
<div className="row" key={`${hit.trace_id}-${hit.seq}`}>
|
<div className="row" key={`${hit.trace_id}-${hit.seq}`}>
|
||||||
|
|
@ -1191,6 +1574,15 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedAlert ? (
|
||||||
|
<AlertDrawer
|
||||||
|
alert={selectedAlert}
|
||||||
|
flowPacket={selectedFlowPacket}
|
||||||
|
evidence={selectedEvidence}
|
||||||
|
onClose={() => setSelectedAlert(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue