Skip to content

Commit fc5e6c9

Browse files
committed
feat(dashboard): show delayed node eligibility time
A node delayed by ReQueueAfterSignal/RequeueAtSignal, a retry-policy backoff or a graph start_delay stores the instant it becomes eligible in State.enqueue_after (epoch ms, picked up when enqueue_after <= now), but NodeRunDetailsResponse never carried it, so the Node Details modal showed a CREATED node with no explanation of the delay. Expose the stored enqueue_after as an optional field on the node run details response (passed through, not recomputed) and render it in the modal only for CREATED states: "Eligible after" while the instant is in the future, "Eligible since" once it has passed and the node is still waiting for a worker. The copy states that this is an eligibility time, not a guaranteed start time. Older records/clients without the field see nothing (no Invalid Date, no epoch zero); already queued or finished nodes carry no waiting message. The wire representation stays epoch milliseconds; formatting to the viewer's locale/timezone happens in a pure helper covered by fake-clock tests (node --test, no new dependencies). The modal pairs each clock reading with the details it was taken for and clears its timers when the details change or it unmounts. Scheduler dispatch logic is untouched. Closes #611 Claude-Session: https://claude.ai/code/session_01DkE5qM85ht9Uoq3aAqcKf7
1 parent 2db4b15 commit fc5e6c9

11 files changed

Lines changed: 213 additions & 4 deletions

File tree

‎dashboard/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ A modern Next.js dashboard for visualizing and managing the Exosphere State Mana
3636
- **Secret Management**: View and manage node secrets securely
3737
- **Schema Validation**: JSON schema rendering with type information
3838
- **Node Details Modal**: Comprehensive node information display
39+
- **Eligibility Time**: Delayed nodes show when they become eligible to run (local timezone), and past-due nodes show how long they have been waiting for a worker
3940

4041
## 🚀 Getting Started
4142

‎dashboard/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "next dev --turbopack",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "next lint"
9+
"lint": "next lint",
10+
"test": "node --test \"src/**/*.test.ts\""
1011
},
1112
"dependencies": {
1213
"@radix-ui/react-slot": "^1.2.4",

‎dashboard/src/components/NodeDetailsModal.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { Button } from '@/components/ui/button';
1414
import { Badge } from '@/components/ui/badge';
1515
import { GraphNode as GraphNodeType, NodeRunDetailsResponse } from '@/types/state-manager';
1616
import { clientApiService } from '@/services/clientApi';
17+
import { getNodeEligibility, formatEligibilityTime } from '@/lib/nodeEligibility';
18+
19+
/** How often the eligibility row re-checks the clock so "scheduled" turns into "waiting" on time. */
20+
const ELIGIBILITY_TICK_MS = 5000;
1721

1822
interface NodeDetailsModalProps {
1923
selectedNode: GraphNodeType | null;
@@ -37,6 +41,24 @@ export const NodeDetailsModal: React.FC<NodeDetailsModalProps> = ({
3741
const [retryState, setRetryState] = useState<'idle' | 'confirm' | 'loading' | 'success' | 'error'>('idle');
3842
const [retryError, setRetryError] = useState<string | null>(null);
3943
const [countdown, setCountdown] = useState<number | null>(null);
44+
// Clock reading paired with the details it was taken for, so a node opened long after page
45+
// load is classified against "now" and never against a stale reading for a previous node.
46+
const [clock, setClock] = useState<{ details: NodeRunDetailsResponse | null; nowMs: number }>({ details: null, nowMs: 0 });
47+
48+
// Tick only while a node is waiting on an eligibility time; every timer is cleared when the
49+
// details change (node picked up, node changed, modal closed) or the component unmounts.
50+
useEffect(() => {
51+
if (!selectedNodeDetails || getNodeEligibility(selectedNodeDetails, Date.now()) === null) return;
52+
const tick = () => setClock({ details: selectedNodeDetails, nowMs: Date.now() });
53+
const first = setTimeout(tick, 0);
54+
const timer = setInterval(tick, ELIGIBILITY_TICK_MS);
55+
return () => {
56+
clearTimeout(first);
57+
clearInterval(timer);
58+
};
59+
}, [selectedNodeDetails]);
60+
61+
const eligibility = clock.details === selectedNodeDetails ? getNodeEligibility(selectedNodeDetails, clock.nowMs) : null;
4062

4163
// Reset retry state when modal closes or node changes
4264
useEffect(() => {
@@ -310,6 +332,21 @@ export const NodeDetailsModal: React.FC<NodeDetailsModalProps> = ({
310332
<span className="text-foreground text-xs">{new Date(selectedNodeDetails.updated_at).toLocaleString()}</span>
311333
</div>
312334
)}
335+
{eligibility && (
336+
<div>
337+
<div className="flex justify-between">
338+
<span className="text-muted-foreground">
339+
{eligibility.kind === 'scheduled' ? 'Eligible after:' : 'Eligible since:'}
340+
</span>
341+
<span className="text-foreground text-xs">{formatEligibilityTime(eligibility.at)}</span>
342+
</div>
343+
<p className="text-xs text-muted-foreground mt-1">
344+
{eligibility.kind === 'scheduled'
345+
? 'Delayed by a start delay, retry policy or requeue signal. A worker can pick this node up any time after this moment; it is not a guaranteed start time.'
346+
: 'Eligible to run and waiting for a worker to pick it up.'}
347+
</p>
348+
</div>
349+
)}
313350
</div>
314351
</div>
315352
</div>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { getNodeEligibility, formatEligibilityTime } from './nodeEligibility.ts';
4+
5+
// Fake clock: every case injects `now` instead of reading Date.now(), so no real timers run.
6+
const NOW_MS = Date.UTC(2026, 0, 15, 12, 0, 0); // 2026-01-15T12:00:00Z
7+
8+
test('future delayed CREATED node is scheduled for its stored eligibility time', () => {
9+
const at = NOW_MS + 90 * 60 * 1000;
10+
const result = getNodeEligibility({ status: 'CREATED', enqueue_after: at }, NOW_MS);
11+
assert.deepEqual(result, { kind: 'scheduled', at: new Date(at) });
12+
});
13+
14+
test('past-due CREATED node is reported as eligible and waiting, not as scheduled', () => {
15+
const at = NOW_MS - 5 * 60 * 1000;
16+
const result = getNodeEligibility({ status: 'CREATED', enqueue_after: at }, NOW_MS);
17+
assert.deepEqual(result, { kind: 'waiting', at: new Date(at) });
18+
});
19+
20+
test('an eligibility time equal to now counts as eligible (matches the server-side <= pick-up rule)', () => {
21+
const result = getNodeEligibility({ status: 'CREATED', enqueue_after: NOW_MS }, NOW_MS);
22+
assert.equal(result?.kind, 'waiting');
23+
});
24+
25+
test('old record without the field shows nothing (no Invalid Date, no epoch zero)', () => {
26+
assert.equal(getNodeEligibility({ status: 'CREATED' }, NOW_MS), null);
27+
assert.equal(getNodeEligibility({ status: 'CREATED', enqueue_after: undefined }, NOW_MS), null);
28+
assert.equal(getNodeEligibility({ status: 'CREATED', enqueue_after: null }, NOW_MS), null);
29+
assert.equal(getNodeEligibility({ status: 'CREATED', enqueue_after: 0 }, NOW_MS), null);
30+
assert.equal(getNodeEligibility({ status: 'CREATED', enqueue_after: Number.NaN }, NOW_MS), null);
31+
});
32+
33+
test('already queued, running or finished nodes carry no waiting message', () => {
34+
const at = NOW_MS + 60 * 1000;
35+
for (const status of ['QUEUED', 'EXECUTED', 'SUCCESS', 'ERRORED', 'TIMEDOUT', 'CANCELLED', 'PRUNED', 'NEXT_CREATED_ERROR'] as const) {
36+
assert.equal(getNodeEligibility({ status, enqueue_after: at }, NOW_MS), null, status);
37+
}
38+
});
39+
40+
test('missing details yield nothing', () => {
41+
assert.equal(getNodeEligibility(null, NOW_MS), null);
42+
assert.equal(getNodeEligibility(undefined, NOW_MS), null);
43+
});
44+
45+
test('the same instant renders in the viewer timezone with a zone label, never re-persisted', () => {
46+
const at = new Date(Date.UTC(2026, 0, 15, 23, 30, 0)); // near a day boundary
47+
const utc = formatEligibilityTime(at, 'en-US', 'UTC');
48+
const tokyo = formatEligibilityTime(at, 'en-US', 'Asia/Tokyo');
49+
const la = formatEligibilityTime(at, 'en-US', 'America/Los_Angeles');
50+
assert.match(utc, /UTC/);
51+
assert.match(tokyo, /GMT\+9|JST/);
52+
assert.match(la, /PST|GMT-8/);
53+
// Different calendar days for the same instant; the instant itself is unchanged.
54+
assert.match(tokyo, /1\/16\/2026/);
55+
assert.match(la, /1\/15\/2026/);
56+
assert.equal(at.getTime(), Date.UTC(2026, 0, 15, 23, 30, 0));
57+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Eligibility of a node that has not been picked up by a worker yet.
3+
*
4+
* `enqueue_after` is the stored epoch-millisecond instant after which the state manager
5+
* lets a worker pick the state up (`enqueue_after <= now` on the server). It explains a delay;
6+
* it does not promise execution at that instant.
7+
*/
8+
export type NodeEligibility =
9+
/** The eligibility time is still ahead of the viewer's clock. */
10+
| { kind: 'scheduled'; at: Date }
11+
/** The eligibility time has passed and the node is still waiting for a worker. */
12+
| { kind: 'waiting'; at: Date };
13+
14+
export interface NodeEligibilityInput {
15+
status: string;
16+
enqueue_after?: number | null;
17+
}
18+
19+
/** Only states a worker has not claimed yet can be waiting on an eligibility time. */
20+
const WAITING_STATUSES: ReadonlySet<string> = new Set(['CREATED']);
21+
22+
/**
23+
* Classify a node's stored eligibility time against an injected clock.
24+
* Returns null when nothing should be shown: the node is not waiting to be picked up,
25+
* or the record carries no usable timestamp (older records, missing field, 0, NaN).
26+
*/
27+
export function getNodeEligibility(
28+
details: NodeEligibilityInput | null | undefined,
29+
nowMs: number,
30+
): NodeEligibility | null {
31+
if (!details || !WAITING_STATUSES.has(details.status)) return null;
32+
33+
const at = details.enqueue_after;
34+
if (typeof at !== 'number' || !Number.isFinite(at) || at <= 0) return null;
35+
36+
return at > nowMs ? { kind: 'scheduled', at: new Date(at) } : { kind: 'waiting', at: new Date(at) };
37+
}
38+
39+
/**
40+
* Render an instant in the viewer's locale and timezone with an explicit zone label.
41+
* Formatting is display-only; the wire representation stays epoch milliseconds.
42+
*/
43+
export function formatEligibilityTime(at: Date, locale?: string, timeZone?: string): string {
44+
return at.toLocaleString(locale, { timeZone, timeZoneName: 'short' });
45+
}

‎dashboard/src/types/state-manager.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ export interface NodeRunDetailsResponse {
217217
parents: Record<string, string>;
218218
created_at: string;
219219
updated_at: string;
220+
/** Epoch milliseconds after which the state is eligible to be enqueued; absent on older records/servers. */
221+
enqueue_after?: number | null;
220222
}
221223

222224
// Runs Types

‎dashboard/tsconfig.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"skipLibCheck": true,
77
"strict": true,
88
"noEmit": true,
9+
"allowImportingTsExtensions": true,
910
"esModuleInterop": true,
1011
"module": "esnext",
1112
"moduleResolution": "bundler",

‎docs/docs/exosphere/dashboard.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ View registered nodes, and graph templates on a namespace
169169
![Runs Overview](../assets/DashboardSS-3.jpg)
170170
View graph runs and debug each node that was created.
171171

172+
Nodes that are still `CREATED` show when they become eligible to run: a node delayed by a start delay, a retry policy or a `ReQueueAfterSignal`/`RequeueAtSignal` shows **Eligible after** with the time in your local timezone, and a node whose eligibility time has already passed shows **Eligible since** while it waits for a worker. This is the stored eligibility time, not a guaranteed start time; a worker picks the node up on its next poll after that moment.
173+
172174
## Using the Dashboard
173175

174176
1. **Configure Connection**:

‎state-manager/app/controller/get_node_run_details.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ async def get_node_run_details(namespace: str, graph_name: str, run_id: str, nod
7171
error=state.error,
7272
parents=parent_identifiers,
7373
created_at=state.created_at.isoformat() if state.created_at else "",
74-
updated_at=state.updated_at.isoformat() if state.updated_at else ""
74+
updated_at=state.updated_at.isoformat() if state.updated_at else "",
75+
enqueue_after=state.enqueue_after
7576
)
7677

7778
logger.info(f"Successfully retrieved node run details for node ID: {node_id}", x_exosphere_request_id=request_id)

‎state-manager/app/models/node_run_details_models.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,12 @@ class NodeRunDetailsResponse(BaseModel):
1616
error: Optional[str] = Field(None, description="Error message if any")
1717
parents: Dict[str, str] = Field(..., description="Parent node identifiers")
1818
created_at: str = Field(..., description="Creation timestamp")
19-
updated_at: str = Field(..., description="Last update timestamp")
19+
updated_at: str = Field(..., description="Last update timestamp")
20+
enqueue_after: Optional[int] = Field(
21+
None,
22+
description=(
23+
"Unix time in milliseconds after which the state becomes eligible to be enqueued. "
24+
"This is the stored eligibility time (set by start delays, retry policies and requeue signals), "
25+
"not a guaranteed execution time: a worker picks the state up on its next poll after this instant."
26+
),
27+
)

0 commit comments

Comments
 (0)