mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-26 01:11:21 +00:00
### Summary
Billing and stage lists now use the event-sourced `RunProjection` as
their source of truth, so running and retrying stages appear immediately
and runtimes keep advancing in the UI. This removes the checkpoint
completed-node bypass that hid in-flight work and froze totals until the
next server response.
### Plan Summary
- Store stage `started_at`, terminal `duration_ms`, server-internal
`usage`, and lifecycle `state` on `StageProjection`.
- Populate those fields from stage lifecycle events, including retry
transitions and per-attempt reset on new starts.
- Render `/runs/{id}/stages` and `/runs/{id}/billing` from
`RunProjection.iter_stages()`.
- Expose the new API/client fields and tick in-flight billing runtimes
on the web UI.
```mermaid
flowchart TB
Events["Stage lifecycle events"] --> Projection["RunProjection StageProjection"]
Projection --> StagesAPI["GET /runs/{id}/stages"]
Projection --> BillingAPI["GET /runs/{id}/billing"]
StagesAPI --> StageUI["Stage sidebar/stages view"]
BillingAPI --> BillingUI["Billing tab live totals"]
```
### Key decisions
Retry and revisit handling stays one row per node id: latest visit data
wins, while first-seen event sequence keeps ordering stable with
finalize output. `state` is stored rather than derived so `Retrying` is
representable, and old serialized projections still work through the
`effective_state()` fallback. Billing `usage` remains server-internal
and is skipped on the wire; public schemas only expose the fields needed
by `/stages`, `/billing`, and the frontend live timer.
Added focused reducer, server retry/revisit, API round-trip, billing UI,
and event invalidation coverage.
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
41 lines
1.5 KiB
TypeScript
41 lines
1.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
/**
|
|
* Re-renders the calling component every `intervalMs` milliseconds while
|
|
* `active` is true, returning the current `Date.now()` value at each tick.
|
|
* Returns the captured value when paused, so renders are stable.
|
|
*/
|
|
export function useTickingNow(active: boolean, intervalMs = 1000): number {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
setNow(Date.now());
|
|
const interval = setInterval(() => setNow(Date.now()), intervalMs);
|
|
return () => clearInterval(interval);
|
|
}, [active, intervalMs]);
|
|
return now;
|
|
}
|
|
|
|
function relativeTime(seconds: number, past: boolean): string {
|
|
if (seconds < 60) return past ? "just now" : "in <1m";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return past ? `${minutes}m ago` : `in ${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return past ? `${hours}h ago` : `in ${hours}h`;
|
|
const days = Math.floor(hours / 24);
|
|
return past ? `${days}d ago` : `in ${days}d`;
|
|
}
|
|
|
|
/**
|
|
* Format an ISO 8601 timestamp as a relative past time string (e.g. "2h ago", "3d ago").
|
|
*/
|
|
export function timeAgo(iso: string): string {
|
|
return relativeTime(Math.floor((Date.now() - new Date(iso).getTime()) / 1000), true);
|
|
}
|
|
|
|
/**
|
|
* Format an ISO 8601 timestamp as a relative future time string (e.g. "in 2h", "in 3d").
|
|
*/
|
|
export function timeUntil(iso: string): string {
|
|
return relativeTime(Math.floor((new Date(iso).getTime() - Date.now()) / 1000), false);
|
|
}
|