harden web terminal ui states
This commit is contained in:
parent
6d11abc660
commit
5538f3faa1
5 changed files with 461 additions and 72 deletions
|
|
@ -1675,6 +1675,7 @@ h3 {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.data-table-cell-number {
|
||||
|
|
@ -2010,11 +2011,16 @@ h3 {
|
|||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 76px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
NAV_ITEMS,
|
||||
appendHistoryTail,
|
||||
buildAlertContextPath,
|
||||
buildTapeStatusAnnouncement,
|
||||
buildDefaultFlowFilters,
|
||||
buildOptionTapeQueryParams,
|
||||
classifierToneForFamily,
|
||||
|
|
@ -51,6 +52,34 @@ import {
|
|||
toggleFilterValue
|
||||
} from "./terminal";
|
||||
|
||||
describe("tape status hardening", () => {
|
||||
it("builds a screen-reader announcement with replay state and queued rows", () => {
|
||||
expect(
|
||||
buildTapeStatusAnnouncement({
|
||||
status: "connected",
|
||||
replayTime: null,
|
||||
replayComplete: false,
|
||||
paused: true,
|
||||
dropped: 12,
|
||||
mode: "replay"
|
||||
})
|
||||
).toBe("Replay feed paused, time not available, 12 queued rows");
|
||||
});
|
||||
|
||||
it("announces stale live feeds without relying on the colored dot", () => {
|
||||
expect(
|
||||
buildTapeStatusAnnouncement({
|
||||
status: "stale",
|
||||
replayTime: null,
|
||||
replayComplete: false,
|
||||
paused: false,
|
||||
dropped: 0,
|
||||
mode: "live"
|
||||
})
|
||||
).toBe("Live feed behind");
|
||||
});
|
||||
});
|
||||
|
||||
const makeItem = (traceId: string, seq: number, ts: number) => ({
|
||||
trace_id: traceId,
|
||||
seq,
|
||||
|
|
|
|||
|
|
@ -2145,6 +2145,59 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode):
|
|||
}
|
||||
};
|
||||
|
||||
export const buildTapeStatusAnnouncement = ({
|
||||
status,
|
||||
replayTime,
|
||||
replayComplete,
|
||||
paused,
|
||||
dropped,
|
||||
mode
|
||||
}: Pick<TapeStatusProps, "status" | "replayTime" | "replayComplete" | "paused" | "dropped" | "mode">): string => {
|
||||
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
|
||||
const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ")
|
||||
? label.toLowerCase()
|
||||
: `feed ${label.toLowerCase()}`;
|
||||
const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`];
|
||||
|
||||
if (mode === "replay") {
|
||||
parts.push(`time ${replayTime ? formatTime(replayTime) : "not available"}`);
|
||||
}
|
||||
|
||||
if (paused && dropped > 0) {
|
||||
parts.push(`${dropped} queued rows`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
const DataCell = ({
|
||||
children,
|
||||
className = "",
|
||||
title,
|
||||
numeric = false
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
numeric?: boolean;
|
||||
}) => {
|
||||
const classes = ["data-table-cell", numeric ? "data-table-cell-number" : "", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<span className={classes} role="cell" title={title}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = ({ children }: { children: ReactNode }) => (
|
||||
<div className="empty" role="status" aria-live="polite">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
type TapeConfig<T> = {
|
||||
mode: TapeMode;
|
||||
wsPath: string;
|
||||
|
|
@ -3893,17 +3946,33 @@ const TapeStatus = ({
|
|||
}: TapeStatusProps) => {
|
||||
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
|
||||
const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : "";
|
||||
const announcement = buildTapeStatusAnnouncement({
|
||||
status,
|
||||
replayTime,
|
||||
replayComplete,
|
||||
paused,
|
||||
dropped,
|
||||
mode
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}>
|
||||
<span className="status-dot" />
|
||||
<div
|
||||
className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={announcement}
|
||||
>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
<span className="status-inline-label">{label}</span>
|
||||
{mode === "replay" ? (
|
||||
<span className="status-inline-meta">
|
||||
Replay time {replayTime ? formatTime(replayTime) : "—"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}>
|
||||
<span
|
||||
className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}
|
||||
aria-hidden={!pausedLabel}
|
||||
>
|
||||
{pausedLabel || "+000 queued"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -7532,7 +7601,7 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
|||
</div>
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.mode === "live"
|
||||
? state.options.status === "stale"
|
||||
? "Feed behind. Waiting for fresh option prints."
|
||||
|
|
@ -7544,29 +7613,23 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
|||
: state.tickerSet.size > 0
|
||||
? "No option prints match the current filter."
|
||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-options" role="table" aria-label="Options tape">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">SYM</span>
|
||||
<span className="data-table-cell">EXP</span>
|
||||
<span className="data-table-cell">STRIKE</span>
|
||||
<span className="data-table-cell">C/P</span>
|
||||
<span className="data-table-cell">SPOT</span>
|
||||
<span className="data-table-cell">DETAILS</span>
|
||||
<span className="data-table-cell">TYPE</span>
|
||||
<span className="data-table-cell">VALUE</span>
|
||||
<span className="data-table-cell">SIDE</span>
|
||||
<span className="data-table-cell">IV</span>
|
||||
<span className="data-table-cell">CLASSIFIER</span>
|
||||
{["TIME", "SYM", "EXP", "STRIKE", "C/P", "SPOT", "DETAILS", "TYPE", "VALUE", "SIDE", "IV", "CLASSIFIER"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="data-table-scroll" ref={state.optionsScroll.setListRef}>
|
||||
<div
|
||||
className="data-table-body"
|
||||
style={{ height: `${virtual.totalSize}px` }}
|
||||
aria-hidden={virtual.virtualItems.length === 0}
|
||||
role="rowgroup"
|
||||
>
|
||||
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => {
|
||||
const contractId = normalizeContractId(print.option_contract_id);
|
||||
|
|
@ -7602,42 +7665,42 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
|||
};
|
||||
const cells = (
|
||||
<>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">
|
||||
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||
<DataCell title={contractId}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell title={contractDisplay?.expiration ?? parsed?.expiry ?? undefined}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
</DataCell>
|
||||
<DataCell numeric title={contractDisplay?.strike ?? undefined}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
</DataCell>
|
||||
<DataCell numeric>{typeof spot === "number" ? formatPrice(spot) : "--"}</DataCell>
|
||||
<DataCell numeric title={`${formatSize(print.size)} at ${formatPrice(print.price)}, ${nbboSide ?? "unknown side"}`}>
|
||||
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
||||
</span>
|
||||
<span className="data-table-cell">{print.option_type ?? "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number notional-emphasis">${formatCompactUsd(notional)}</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell title={print.option_type ?? undefined}>{print.option_type ?? "--"}</DataCell>
|
||||
<DataCell numeric className="notional-emphasis" title={`$${formatUsd(notional)}`}>${formatCompactUsd(notional)}</DataCell>
|
||||
<DataCell>
|
||||
{nbboSide ? (
|
||||
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">{typeof iv === "number" ? formatPct(iv) : "--"}</span>
|
||||
<span className="data-table-cell">{decor ? humanizeClassifierId(decor.family) : "--"}</span>
|
||||
</DataCell>
|
||||
<DataCell numeric>{typeof iv === "number" ? formatPct(iv) : "--"}</DataCell>
|
||||
<DataCell title={decor ? humanizeClassifierId(decor.family) : undefined}>{decor ? humanizeClassifierId(decor.family) : "--"}</DataCell>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -7721,7 +7784,7 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
>
|
||||
<div className="data-table-shell">
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.mode === "live"
|
||||
? state.equities.status === "stale"
|
||||
? "Feed behind. Waiting for fresh equity prints."
|
||||
|
|
@ -7735,23 +7798,23 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
: state.tickerSet.size > 0
|
||||
? "No equity prints match the current filter."
|
||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-equities" role="table" aria-label="Equity prints">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">SYM</span>
|
||||
<span className="data-table-cell">PRICE</span>
|
||||
<span className="data-table-cell">SIZE</span>
|
||||
<span className="data-table-cell">VENUE</span>
|
||||
<span className="data-table-cell">TAPE</span>
|
||||
{["TIME", "SYM", "PRICE", "SIZE", "VENUE", "TAPE"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="data-table-scroll" ref={state.equitiesScroll.setListRef}>
|
||||
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}>
|
||||
<div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
|
||||
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => (
|
||||
<div
|
||||
className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`}
|
||||
role="row"
|
||||
key={key}
|
||||
data-index={index}
|
||||
data-row-start={String(start)}
|
||||
|
|
@ -7759,8 +7822,8 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">
|
||||
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||
<DataCell title={print.underlying_id}>
|
||||
<button
|
||||
className="instrument-cell-button"
|
||||
type="button"
|
||||
|
|
@ -7768,11 +7831,11 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
>
|
||||
{print.underlying_id}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span>
|
||||
<span className="data-table-cell">{print.exchange}</span>
|
||||
<span className="data-table-cell">{print.offExchangeFlag ? "Off-Ex" : "Lit"}</span>
|
||||
</DataCell>
|
||||
<DataCell numeric>${formatPrice(print.price)}</DataCell>
|
||||
<DataCell numeric>{formatSize(print.size)}x</DataCell>
|
||||
<DataCell title={print.exchange}>{print.exchange}</DataCell>
|
||||
<DataCell>{print.offExchangeFlag ? "Off-Ex" : "Lit"}</DataCell>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -7825,7 +7888,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
>
|
||||
<div className="data-table-shell">
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.tickerSet.size > 0
|
||||
? "No flow packets match the current filter."
|
||||
: state.mode === "live"
|
||||
|
|
@ -7833,23 +7896,19 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
? "Feed behind. Waiting for fresh flow packets."
|
||||
: "No flow packets yet. Start compute."
|
||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-flow" role="table" aria-label="Flow packets">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">CONTRACT</span>
|
||||
<span className="data-table-cell">PRINTS</span>
|
||||
<span className="data-table-cell">SIZE</span>
|
||||
<span className="data-table-cell">NOTIONAL</span>
|
||||
<span className="data-table-cell">WINDOW</span>
|
||||
<span className="data-table-cell">STRUCTURE</span>
|
||||
<span className="data-table-cell">NBBO</span>
|
||||
<span className="data-table-cell">QUALITY</span>
|
||||
{["TIME", "CONTRACT", "PRINTS", "SIZE", "NOTIONAL", "WINDOW", "STRUCTURE", "NBBO", "QUALITY"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="data-table-scroll" ref={state.flowScroll.setListRef}>
|
||||
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}>
|
||||
<div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
|
||||
{virtual.virtualItems.map(({ item: packet, key, index, start, size }) => {
|
||||
const features = packet.features ?? {};
|
||||
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
||||
|
|
@ -7904,6 +7963,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
return (
|
||||
<div
|
||||
className={`data-table-row data-table-row-flow data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}${nbboStale || nbboMissing ? " data-table-row-warn" : ""}`}
|
||||
role="row"
|
||||
key={key}
|
||||
data-index={index}
|
||||
data-row-start={String(start)}
|
||||
|
|
@ -7911,15 +7971,15 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(startTs)} → {formatTime(endTs)}</span>
|
||||
<span className="data-table-cell">{contract}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(count)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(totalSize)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">${formatUsd(notional)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</span>
|
||||
<span className="data-table-cell">{structureLabel}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{nbboLabel}</span>
|
||||
<span className="data-table-cell">{qualityLabel || "--"}</span>
|
||||
<DataCell numeric title={`${formatDateTime(startTs)} to ${formatDateTime(endTs)}`}>{formatTime(startTs)} → {formatTime(endTs)}</DataCell>
|
||||
<DataCell title={contract}>{contract}</DataCell>
|
||||
<DataCell numeric>{formatFlowMetric(count)}</DataCell>
|
||||
<DataCell numeric>{formatFlowMetric(totalSize)}</DataCell>
|
||||
<DataCell numeric>${formatUsd(notional)}</DataCell>
|
||||
<DataCell numeric>{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</DataCell>
|
||||
<DataCell title={structureLabel !== "--" ? structureLabel : undefined}>{structureLabel}</DataCell>
|
||||
<DataCell numeric title={nbboLabel !== "--" ? nbboLabel : undefined}>{nbboLabel}</DataCell>
|
||||
<DataCell title={qualityLabel || undefined}>{qualityLabel || "--"}</DataCell>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue