Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion tests/health_today_incomplete_ui_validation.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { runActivityState, runActivityCounts, todayFailures } from '../web/strategy-switch-console/frontend/src/pages/health/status.ts';
import { runActivityState, runActivityCounts, todayFailures, weekendWithoutScheduledRuns } from '../web/strategy-switch-console/frontend/src/pages/health/status.ts';
const base = { status:'ok', activity:'read_incomplete', completeness:'incomplete', run_status:'incomplete', read_error_count:1, read_error_codes:['coverage_unconfirmed'], run_count:2, runs:[{activity:'unknown',errors_present:null},{activity:'no_rebalance',errors_present:false}] };
// Old contract: the same record with `run_status` omitted keeps classifying from
// the raw fields, so the conservative code/count/failure rules stay covered.
Expand Down Expand Up @@ -125,4 +125,26 @@ assert.equal(runActivityState({...clean, activity:'read_incomplete', completenes
// Partial summary with a null total stays incomplete-or-unknown, never complete.
assert.deepEqual(todayFailures({status:'partial',counts:{runs:null,configured:1,observed:1},
accounts:[{...clean, run_status:'incomplete'}]}),{count:0,complete:false});

// ---------------------------------------------------------------------------
// Weekend semantics. Absent records on a non-trading day are the schedule, not
// degraded coverage; genuine degradation must keep its normal presentation.
// ---------------------------------------------------------------------------
const dayAccount = (status, unavailable_reason = null) => ({ status, unavailable_reason });
const weekend = accounts => ({ market_session:'weekend', accounts });
const tradingDay = accounts => ({ market_session:'trading_day', accounts });

assert.equal(weekendWithoutScheduledRuns(weekend([dayAccount('ok'), dayAccount('no_data','non_trading_weekend')])), true,
'a clean weekend with a non-trading account is not degraded');
assert.equal(weekendWithoutScheduledRuns(weekend([dayAccount('no_data','non_trading_weekend'), dayAccount('no_data','today_record_missing')])), true,
'a crypto account still waiting for its later snapshot stays a non-trading weekend');
for (const [status, reason] of [['stale',null],['read_failed',null],['partial',null],
['not_connected',null],['no_data',null],['no_data','unavailable'],['no_data','read_failed']]) {
assert.equal(weekendWithoutScheduledRuns(weekend([dayAccount('ok'), dayAccount(status, reason)])), false,
`weekend account ${status}/${reason} keeps its degraded presentation`);
}
assert.equal(weekendWithoutScheduledRuns(tradingDay([dayAccount('ok'), dayAccount('no_data','non_trading_weekend')])), false,
'a trading day is never contextualised as non-trading');
assert.equal(weekendWithoutScheduledRuns({ accounts:[dayAccount('ok')] }), false,
'an old payload without market_session is not declared non-trading');
console.log('health today incomplete UI: PASS');
14 changes: 10 additions & 4 deletions web/strategy-switch-console/frontend/src/i18n/pages/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ export const zh: Record<string, string> = {

"state.fresh": "最新",
"state.unknown": "未知",
"decisions.partial_sources": "部分数据不可用",
"decisions.partial_notice": "部分数据不可用或不是最新,列表可能不完整。",
"decisions.partial_notice": "部分来源不可用或不是最新,对应类别的待办可能不完整。",
"decisions.partial_affected": "受影响来源",
"decisions.partial_consequence.stale": "数据偏旧,该类别的待办可能不完整。",
"decisions.partial_consequence.unavailable": "暂不可用,该类别的待办无法确认。",
"decisions.partial_consequence.read_failed": "读取失败,该类别的待办无法确认。",
"decisions.unknown_source": "其他来源",
"decisions.material_freshness": "数据时效:{value}",
"decisions.stale_hint": "数据可能不是最新",
Expand Down Expand Up @@ -190,8 +193,11 @@ export const en: Record<string, string> = {

"state.fresh": "Fresh",
"state.unknown": "Unknown",
"decisions.partial_sources": "Some data is unavailable",
"decisions.partial_notice": "Some data is unavailable or out of date; this list may be incomplete.",
"decisions.partial_notice": "Some sources are unavailable or out of date; the affected categories may be incomplete.",
"decisions.partial_affected": "Affected sources",
"decisions.partial_consequence.stale": "out of date; this category may be incomplete.",
"decisions.partial_consequence.unavailable": "unavailable; this category's pending items cannot be confirmed.",
"decisions.partial_consequence.read_failed": "failed to load; this category's pending items cannot be confirmed.",
"decisions.unknown_source": "Other source",
"decisions.material_freshness": "Data freshness: {value}",
"decisions.stale_hint": "Data may not be up to date",
Expand Down
8 changes: 8 additions & 0 deletions web/strategy-switch-console/frontend/src/i18n/pages/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export const zh: Record<string, string> = {
"health.kpi.recent_failure": "今日失败账户",
"health.business_date": "纽约业务日 {value}",
"health.non_trading_day": "非交易日:周末无计划运行;加密货币账户可能仍有记录。",
"health.non_trading_day_short": "非交易日",
"health.grid_read_failed_note": "部分日期读取失败,无法确认当天是否运行。",
"health.grid.binding_changed": "绑定已轮换",
"health.grid.binding_changed_detail": "该日期记录写于绑定轮换之前,无法与当前账户绑定匹配,因此不展示;这不代表当天未运行。",
"health.grid_binding_changed_note": "部分日期属于绑定轮换前的历史记录;记录已保留,但不会与当前账户绑定混用。",
"health.global_scope_note": "本页展示全部平台与账户的运行状态。",
"health.grid.incomplete": "未完成",
"health.col.credential": "凭证",
Expand Down Expand Up @@ -52,7 +56,11 @@ export const en: Record<string, string> = {
"health.kpi.recent_failure": "Today's failed accounts",
"health.business_date": "New York business day {value}",
"health.non_trading_day": "Non-trading day: no scheduled weekend runs; crypto accounts may still report.",
"health.non_trading_day_short": "Non-trading day",
"health.grid_read_failed_note": "Some dates could not be read, so whether a run occurred is unknown.",
"health.grid.binding_changed": "Binding rotated",
"health.grid.binding_changed_detail": "This day was recorded under an earlier binding and no longer matches the current account binding, so it is not shown. It does not mean no run occurred.",
"health.grid_binding_changed_note": "Some dates predate a binding rotation; their records are retained but never mixed with the current account binding.",
"health.global_scope_note": "This page shows runtime status for all platforms and accounts.",
"health.grid.incomplete": "Incomplete",
"health.col.credential": "Credential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const zh: Record<string, string> = {
"overview.runs_card_title": "今日有记录账户",
"overview.business_date": "纽约业务日 {value}",
"overview.no_records_for_day": "当前未观测到该业务日记录",
"overview.non_trading_day_short": "非交易日",
};
export const en: Record<string, string> = {
"overview.asset_summary": "Asset summary",
Expand All @@ -20,4 +21,5 @@ export const en: Record<string, string> = {
"overview.runs_card_title": "Accounts with records today",
"overview.business_date": "New York business day {value}",
"overview.no_records_for_day": "No records observed for this business day",
"overview.non_trading_day_short": "Non-trading day",
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const zh: Record<string, string> = {
"strategies.health_label": "策略健康",
"strategies.health_not_provided": "未提供",
"strategies.execution_section": "账户执行模式",
"strategies.execution_unknown_note": "未知表示账户目录或运行目标还缺少明确的执行配置;补齐后会自动确认。",
"strategies.domain_note": "策略健康来自策略评估;执行模式来自账户配置。两者都不代表平台今日运行结果。",
"reason.strategy_health_stale": "策略健康数据已过期。",
"strategies.details": "详细信息",
Expand Down Expand Up @@ -38,6 +39,7 @@ export const en: Record<string, string> = {
"strategies.health_label": "Strategy health",
"strategies.health_not_provided": "Not provided",
"strategies.execution_section": "Account execution mode",
"strategies.execution_unknown_note": "Unknown means the account directory or runtime target still lacks explicit execution configuration; it confirms automatically once supplied.",
"strategies.domain_note": "Strategy health comes from strategy evaluation; execution mode comes from account configuration. Neither establishes today's platform run result.",
"reason.strategy_health_stale": "Strategy health data is stale.",
"strategies.details": "Details",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { Timeline } from "../../components/Timeline";
import { ConfirmationDialog, useConfirmation } from "../../components/Dialog";
import { useToast } from "../../components/Toast";
import { resolveAccountChoice } from "./helpers";
import { sourceLabelKey } from "./sources";
import { sourceConsequenceKey, sourceLabelKey } from "./sources";
import { InitialEnrollmentFacts } from "./InitialEnrollmentFacts";
import { ReconciliationMaterials } from "./ReconciliationMaterials";
import "./decisions.css";
Expand Down Expand Up @@ -837,17 +837,17 @@ export default function DecisionsPage() {
data-degraded-count={degradedSources.length}
>
<StatusBadge status="partial" />
<span>{t("decisions.partial_notice")}</span>
<details className="decision-degraded-sources">
<summary>{t("decisions.partial_sources")}</summary>
<ul>
<div className="decision-degraded-body">
<span>{t("decisions.partial_notice")}</span>
<ul className="decision-degraded-sources" aria-label={t("decisions.partial_affected")}>
{degradedSources.map(source => (
<li key={source.source} data-source={source.source}>
{t(sourceLabelKey(source.source))} · {t(`state.${source.status}`)}
<strong>{t(sourceLabelKey(source.source))}</strong>
<span> · {t(`state.${source.status}`)} — {t(sourceConsequenceKey(source.status))}</span>
</li>
))}
</ul>
</details>
</div>
</div>
)}
<div id="decisions-panel" role="tabpanel" aria-labelledby={`decisions-tab-${category}`} tabIndex={0}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
.decisions-category-row .tabs::-webkit-scrollbar { display: none; }
.decisions-category-row .tab { flex: 0 0 auto; white-space: nowrap; }

/* Persistent partial-source notice: visible text, expandable source names. */
/* Persistent partial-source notice: each degraded source states its own effect. */
.decision-degraded-note {
display: flex;
align-items: center;
Expand All @@ -37,9 +37,10 @@
font-size: 12.5px;
line-height: 1.5;
}
.decision-degraded-sources { font-size: 12px; color: var(--muted); }
.decision-degraded-sources > summary { display: inline-flex; align-items: center; min-height: 28px; cursor: pointer; }
.decision-degraded-sources ul { margin: 4px 0 0; padding-left: 18px; }
.decision-degraded-body { display: flex; flex-direction: column; gap: 2px; flex: 1 1 100%; }
.decision-degraded-sources { margin: 2px 0 0; padding-left: 18px; font-size: 12px; color: var(--muted); }
.decision-degraded-sources li + li { margin-top: 2px; }
.decision-degraded-sources strong { font-weight: 600; color: var(--ink); }

.decision-detail-head { display: flex; flex-direction: column; gap: 8px; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@ export function sourceLabelKey(source: string): MessageKey {
const category = SOURCE_CATEGORY[source];
return category ? (`category.${category}` as MessageKey) : "decisions.unknown_source";
}

/** What a degraded source means for the list, stated per source. */
export function sourceConsequenceKey(status: string): MessageKey {
if (status === "read_failed") return "decisions.partial_consequence.read_failed";
if (status === "unavailable") return "decisions.partial_consequence.unavailable";
return "decisions.partial_consequence.stale";
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
StatusBadge,
} from "../../components/primitives";
import { RunGrid } from "./RunGrid";
import { runActivityCounts, runActivityLabelKey, runActivityState, todayFailures } from "./status";
import { runActivityCounts, runActivityLabelKey, runActivityState, todayFailures, weekendWithoutScheduledRuns } from "./status";
import { groupAccounts } from "../../lib/accountDirectory";
import { boundDailyAccounts } from "./dailySelection";
import "./health.css";
Expand Down Expand Up @@ -65,6 +65,9 @@ function StatStrip({
const okCount = data ? data.accounts.filter(account => account.status === "ok").length : 0;
const failures = data ? todayFailures(data) : { count: 0, complete: false };
const stateCounts = data ? runActivityCounts(data.accounts) : null;
// Weekend records are absent because nothing was scheduled, not because the
// day is degraded. Binance may still report later; the day context says so.
const nonTrading = data ? weekendWithoutScheduledRuns(data) : false;
const unconnectedNote = stateCounts && (stateCounts.not_connected > 0 || stateCounts.no_data > 0)
? t("health.kpi.today_runs_note", { not_connected: stateCounts.not_connected, no_data: stateCounts.no_data })
: undefined;
Expand All @@ -86,9 +89,12 @@ function StatStrip({
<KpiCard
title={t("health.kpi.today_runs")}
value={
<StatValue state={summary} render={() => t("health.account_fraction", { count: okCount, total: data?.counts.configured ?? 0 })} />
<StatValue state={summary} render={() => nonTrading && (data?.counts.observed ?? 0) === 0
? "—"
: t("health.account_fraction", { count: okCount, total: data?.counts.configured ?? 0 })} />
}
sub={unconnectedNote ? <span title={t("health.kpi.today_runs_note_title")}>{unconnectedNote}</span> : undefined}
sub={nonTrading ? <span>{t("health.non_trading_day_short")}</span>
: unconnectedNote ? <span title={t("health.kpi.today_runs_note_title")}>{unconnectedNote}</span> : undefined}
footer={data?.as_of ? <AsOfStamp value={data.as_of} /> : undefined}
/>
<KpiCard
Expand All @@ -102,9 +108,11 @@ function StatStrip({
/>
<KpiCard
title={t("health.kpi.recent_failure")}
value={<StatValue state={summary} render={() => failures.complete
? String(failures.count)
: failures.count > 0 ? t("health.failure_at_least", { count: failures.count }) : t("state.unknown")} />}
value={<StatValue state={summary} render={() => nonTrading && failures.count === 0 ? "—"
: failures.complete
? String(failures.count)
: failures.count > 0 ? t("health.failure_at_least", { count: failures.count }) : t("state.unknown")} />}
sub={nonTrading ? t("health.non_trading_day_short") : undefined}
/>
<KpiCard
title={t("health.kpi.credentials")}
Expand All @@ -130,7 +138,7 @@ function StatStrip({
)}
<p className="metric-note" title={t("health.failure_window_detail")}>
{t("health.failure_window", { date: data?.date ?? "—" })}
{!failures.complete && ` · ${t("health.failure_incomplete")}`}
{nonTrading ? ` · ${t("health.non_trading_day_short")}` : !failures.complete && ` · ${t("health.failure_incomplete")}`}
</p>
</>
);
Expand Down
31 changes: 26 additions & 5 deletions web/strategy-switch-console/frontend/src/pages/health/RunGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@ import type { GridAccount } from "./dailySelection";
type CellState =
| { phase: "loading" }
| { phase: "ready"; snapshot: RuntimeDailySnapshot }
| { phase: "binding-changed" }
| { phase: "error" };

/**
* A stored day written under a previous binding is intentionally withheld by
* the API (409) so a rotation can never silently re-attribute it. That is a
* distinct, explainable state, not an unreadable date.
*/
function isBindingChanged(error: unknown): boolean {
const failure = error as { status?: unknown; message?: unknown } | null;
return failure?.status === 409 && failure?.message === "runtime_daily_binding_changed";
}

const CELL_LABEL_KEY: Record<CellKind, MessageKey> = {
ok: "health.grid.ok",
failed: "health.grid.failed",
Expand All @@ -24,6 +35,7 @@ const CELL_LABEL_KEY: Record<CellKind, MessageKey> = {
incomplete: "health.grid.incomplete",
stale: "state.stale",
"read-failed": "state.read_failed",
"binding-changed": "health.grid.binding_changed",
};

const CELL_ICON: Record<CellKind, string> = {
Expand All @@ -36,6 +48,7 @@ const CELL_ICON: Record<CellKind, string> = {
incomplete: "…",
stale: "~",
"read-failed": "!",
"binding-changed": "↻",
};

function cellKey(account: GridAccount, date: string): string {
Expand Down Expand Up @@ -67,6 +80,8 @@ function DayDetail({ selection, cell }: { selection: Selection; cell: CellState
let body;
if (!cell || cell.phase === "loading") {
body = <Skeleton height={16} width={240} />;
} else if (cell.phase === "binding-changed") {
body = <span className="health-grid-note health-grid-note-info">{t("health.grid.binding_changed_detail")}</span>;
} else if (cell.phase === "error") {
body = <span className="health-grid-note">{t("state.read_failed")}</span>;
} else if (!cell.snapshot.record) {
Expand Down Expand Up @@ -153,6 +168,8 @@ export function RunGrid({ accounts, epoch }: { accounts: GridAccount[]; epoch: n
const key = cellKey(accounts[index], date);
if (result.status === "fulfilled") {
patch[key] = { phase: "ready", snapshot: result.value };
} else if (isBindingChanged(result.reason)) {
patch[key] = { phase: "binding-changed" };
} else {
patch[key] = { phase: "error" };
anyError = true;
Expand All @@ -172,6 +189,7 @@ export function RunGrid({ accounts, epoch }: { accounts: GridAccount[]; epoch: n
if (!accounts.length) return <EmptyState title={t("state.no_data")} />;

const legendKinds = Object.keys(CELL_LABEL_KEY) as CellKind[];
const hasBindingChanged = Object.values(cells).some(cell => cell.phase === "binding-changed");

return (
<>
Expand Down Expand Up @@ -206,16 +224,18 @@ export function RunGrid({ accounts, epoch }: { accounts: GridAccount[]; epoch: n
</td>
);
}
if (cell.phase === "error") {
if (cell.phase === "binding-changed" || cell.phase === "error") {
const kind: CellKind = cell.phase === "error" ? "read-failed" : "binding-changed";
const label = t(CELL_LABEL_KEY[kind]);
return (
<td key={date}>
<button
type="button"
className={`health-cell read-failed${isSelected ? " selected" : ""}`}
title={t("state.read_failed")}
aria-label={`${names.account(account.account_alias, account.platform)} ${date}: ${t("state.read_failed")}`}
className={`health-cell ${kind}${isSelected ? " selected" : ""}`}
title={label}
aria-label={`${names.account(account.account_alias, account.platform)} ${date}: ${label}`}
onClick={() => setSelected({ account, date })}
>!</button>
>{CELL_ICON[kind]}</button>
</td>
);
}
Expand Down Expand Up @@ -250,6 +270,7 @@ export function RunGrid({ accounts, epoch }: { accounts: GridAccount[]; epoch: n
))}
</div>
{historyError && <p className="health-grid-note">{t("health.grid_read_failed_note")}</p>}
{hasBindingChanged && <p className="health-grid-note health-grid-note-info">{t("health.grid_binding_changed_note")}</p>}
{selected && <DayDetail selection={selected} cell={cells[cellKey(selected.account, selected.date)]} />}
</>
);
Expand Down
Loading
Loading