Skip to content

Commit 54f6a09

Browse files
committed
feat(dashboard): redesign shell around the instrument-panel direction
Sidebar grouped into Observe/Operate with the live-rail active marker and the heartbeat brand mark; command-bar topbar ("Jump to…"); refined tokens. Direction, live-rail signature and status-readout pattern codified in DESIGN.md.
1 parent 53f7ea5 commit 54f6a09

4 files changed

Lines changed: 423 additions & 88 deletions

File tree

durable_dashboard/DESIGN.md

Lines changed: 267 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,40 @@ Keyboard hint inside command palette / tooltips.
268268

269269
### `<.relative_time at={dt}>`
270270

271-
Humanized "2m ago" with absolute ISO in `title`. Always use this; never
272-
hand-format times.
271+
Humanized "2m ago" (past) or "in 2h" (future) for durations. Always use this;
272+
never hand-format times. It is sign-aware — a future timestamp (e.g. a
273+
schedule's next run) reads "in …", never "just now". The hover tooltip is
274+
localized to the viewer's timezone by the same `local_time.ts` hook (it carries
275+
`data-rel` so only the title is rewritten; the relative text stays). Use
276+
`local_time` instead when the user needs the exact moment shown inline.
277+
278+
### `<.local_time at={dt} format="time|datetime|date">`
279+
280+
For **absolute** timestamps the user reads. The server only knows UTC, so a
281+
raw ISO string is ambiguous; this emits a `<time data-ts data-format>` with
282+
a UTC fallback as its text, and the client rewrites it to the viewer's
283+
timezone (`assets/src/hooks/local_time.ts`, wired via LiveSocket's `dom`
284+
callbacks — no per-element id needed). Never print a bare UTC timestamp;
285+
use `relative_time` for "ago" durations and `local_time` for the moment.
286+
287+
### `<.label>` and `<.field key="…">`
288+
289+
The canonical "this is a label, not a value" treatment: small mono,
290+
uppercase, `tracking-[0.14em]`, `text-muted-foreground/70`. Use `<.label>`
291+
for **every** field-name / section-header / metadata-key — never hand-roll
292+
the `font-mono … uppercase tracking-…` string again (it was previously
293+
duplicated across the inspector, logs, and IO panels).
294+
295+
```heex
296+
<.label>level</.label>
297+
<.label class="text-destructive">error</.label>
298+
```
299+
300+
`<.field key="level">info</.field>` is the key/value detail row built on
301+
`<.label>`: a fixed-width label column beside a monospace value. Use inside
302+
a `<dl>` for aligned metadata tables (step inspector, log detail, etc.).
303+
Labels read as labels and values as values — keys are never lowercase plain
304+
text indistinguishable from their values.
273305

274306
### `<.skeleton>`, `<.empty_state>`, `<.error_state>`
275307

@@ -317,10 +349,29 @@ its own `Phoenix.LiveComponent` module under
317349
</main>
318350
```
319351

320-
- Sidebar: collapsible, lucide icons, fixed width when expanded.
321-
- Topbar: 28 h, breadcrumbs left, theme toggle + ⌘K hint right.
322-
- Main: `max-w-screen-2xl` to keep line lengths readable on ultrawide
323-
monitors.
352+
- Sidebar: fixed 220 px, `bg-card/40` + hairline right border, heroicons.
353+
Nav is split into two intent groups under quiet mono eyebrows —
354+
**Observe** (Overview, Workflows, Executions) and **Operate** (Inputs,
355+
Schedules, Settings) — so the destinations read as an IA, not a flat list.
356+
Brand lockup is the **monitor tile + heartbeat** (a pulsing `led-dot` in a
357+
primary-tinted square): the recurring "the engine is alive and durable"
358+
mark, echoed by the `connected` heartbeat in the footer.
359+
- Topbar: 56 h, breadcrumbs left; the command-palette trigger ("Jump to…",
360+
⌘K) + theme toggle right.
361+
- Main: `max-w-[1400px]` to keep line lengths readable on ultrawide monitors.
362+
363+
#### The live rail (signature)
364+
365+
The accent (`--primary` indigo) carries **one** meaning across the whole
366+
console: *now / live / you are here*. It is not generic chrome. It appears as:
367+
368+
- the **active nav rail** — a short `h-4 w-0.5` indigo bar on the left edge
369+
of the current nav item;
370+
- the **running edge** in the workflow graph (`.flow-edge-running`);
371+
- the **"now" line** / in-flight bars in the Timeline.
372+
373+
Same hue, same idea, everywhere. Don't spend primary on decoration — if
374+
something is tinted indigo it should mean "live / current."
324375

325376
### Page header
326377

@@ -343,12 +394,63 @@ Subtitle = relative time, scope context, or counts. Never marketing copy.
343394
- Hover: `bg-accent/50`.
344395
- Click anywhere on the row → `live_patch` to detail.
345396
- Right-aligned secondary actions reveal on hover.
397+
- **Column headers** use the canonical `<.label>` idiom — `font-mono
398+
text-[10px] uppercase tracking-[0.14em] text-muted-foreground/70` — so a
399+
table header reads as a label, identical to field-name labels in the detail
400+
panels. This is baked into the `DataTable` LiveComponent; hand-rolled tables
401+
(e.g. Overview's "recent executions") apply the same classes. Sortable
402+
headers add `hover:text-foreground`. Never reach for sans `tracking-wider`.
403+
404+
### Status readout (instrument cluster)
405+
406+
Aggregate counts (Overview's status KPIs, a workflow card's runs/failed/
407+
waiting) render as a **segmented readout**, not a row of free-floating cards:
408+
one panel with hairline-divided cells, so it reads like an instrument cluster.
409+
410+
- Draw the dividers with the **`gap-px` + `bg-border` trick** when the grid
411+
wraps responsively (container `bg-border`, cells `bg-card`, `gap-px`);
412+
use `divide-x divide-border` only for a fixed single row that never wraps.
413+
- Numbers are **mono** (`text-numeric`) — they're machine facts. Labels use
414+
`<.label>`.
415+
- **Restraint of color:** the dot carries status; only the counts that signal
416+
trouble light up (failed → destructive, waiting → warning, when `> 0`).
417+
Running pulses (`led-dot`). Everything else stays neutral, so the eye lands
418+
on what's wrong instead of a rainbow. No gradient glows.
346419

347420
### Detail sheet / drawer
348421

349422
`data-slot="sheet-content"` is styled centrally in `index.css`. **Don't**
350423
override its background or backdrop-blur in the sheet itself.
351424

425+
### Step detail panel (`Components.Workflow.StepDetail`)
426+
427+
The in-place inspector for a single step execution — **one** component
428+
shared by the Timeline (click a bar's row) and History (click a trace
429+
event), so a step reads identically wherever it's opened. Composition:
430+
431+
- **Stat strip** — a compact horizontal header (`started · completed ·
432+
duration · attempt`), never a tall field column that leaves dead space.
433+
- **Input / Output**`<.json>` boxes sized `w-fit max-w-full` so a small
434+
payload doesn't render a giant empty box.
435+
- **Error** — destructive-tinted `<.json>`, only when the step failed.
436+
- **Logs** — the shared `<LogLine.row>` list; empty falls back to a
437+
"No logs captured" note rather than a blank box.
438+
439+
Pass `class` for the caller's margins, `duration_ms` to override (Timeline
440+
sums it across resume segments).
441+
442+
### Trace spine (execution history)
443+
444+
A chronological list where order is itself information (a failure and its
445+
retry are two adjacent events) renders as a **vertical trace**, not a flat
446+
table: a hairline spine down the left with a status-colored node bead per
447+
event (`ring-4 ring-card` masks the connector behind the bead; the
448+
connector is trimmed at the first/last events). Uniform node beads — never
449+
variable-width status pills lined up in a column. Each event is a native
450+
`<details>`; the row is the `<summary>`, expanding `StepDetail` inline.
451+
Reserve this for genuinely sequential data — for unordered lists use the
452+
plain List/table row above.
453+
352454
### Empty / loading / error shells
353455

354456
For every list LV:
@@ -423,12 +525,13 @@ groups. Background is the dot-grid surface ReactFlow ships.
423525

424526
### Node types
425527

426-
| Type | Component | Lucide icon | Purpose |
427-
| ----------- | --------------- | ----------- | ---------------------------------- |
428-
| `start` | `StartNode` | `Play` | Boundary marker (success tone) |
429-
| `end` | `EndNode` | `Flag` | Boundary marker |
430-
| `step` | `StepNode` | `Box` | Regular step (clickable) |
431-
| `decision` | `GatewayNode` | `GitBranch` | Conditional branching point |
528+
| Type | Component | Lucide icon | Purpose |
529+
| ---------------- | -------------------- | ----------- | -------------------------------------------- |
530+
| `start` | `StartNode` | `Play` | Boundary marker (success tone) |
531+
| `end` | `EndNode` | `Flag` | Boundary marker |
532+
| `step` | `StepNode` | `Box` | Regular step (clickable) |
533+
| `decision` | `GatewayNode` | `GitBranch` | Conditional branching point |
534+
| `child_workflow` | `ChildWorkflowNode` | `GitFork` | Step that spawned its own child execution |
432535

433536
There are **no marker nodes** for parallel fork/join or branch
434537
fork/join. Fan-out and fan-in are expressed purely through edge
@@ -447,19 +550,34 @@ A single custom edge component (`AnimatedFlowEdge`) — a flowing bezier
447550
path. Status-driven stroke is set by classes the server attaches via
448551
`graph_builder.overlay_status/3`:
449552

450-
- `.flow-edge-completed``--success` solid, 1.5 px.
553+
- `.flow-edge-completed``--success` solid, 1.5 px. Static by
554+
default in linear flow (animating every completed edge in a long
555+
workflow is too noisy).
451556
- `.flow-edge-running``--primary` with `dash-flow` animation.
452557
- `.flow-edge-pending``--border` 1 px dashed.
453558
- `.flow-edge-conditional``--primary/55` dashed. Used for
454559
decision-step `{:goto, :target, _}` paths extracted at compile time
455-
by `Durable.DSL.Step.build_decision/3`. Stays dashed regardless of
456-
runtime state because it represents a *possible* path, not a
457-
*taken* one.
560+
by `Durable.DSL.Step.build_decision/3`. **Repainted at runtime** when
561+
the goto target actually executed: takes on `flow-edge-completed`
562+
styling **and the dash-flow animation** for the taken path; only the
563+
*un-taken* branches keep the dashed conditional look. The animation
564+
is the deliberate signal — at a decision node it's the only visual
565+
cue distinguishing the executed branch from the still-dashed
566+
alternatives. See `graph_builder.apply_edge_status/3`.
567+
568+
Hover and selected states layer on top of those resting strokes:
569+
570+
- `:hover` bumps stroke to 2 px and adds a subtle `--primary 35%`
571+
drop-shadow filter so the edge "lifts" under the cursor.
572+
- `.selected` / `:focus-visible` re-paints the stroke with `--primary`
573+
at 2.25 px so the active edge is unambiguous when the user
574+
click-selects via ReactFlow.
458575

459576
Edge labels (branch clause names like `low` / `high` / `default`, and
460577
decision goto targets like `goto :auto_remove`) render as
461578
`<.badge kind="muted">`-style chips at the bezier midpoint via
462-
`<EdgeLabelRenderer>`.
579+
`<EdgeLabelRenderer>`. Their background tints toward `--primary` on the
580+
parent edge's hover/selected state.
463581

464582
### "Current execution" emphasis
465583

@@ -468,10 +586,40 @@ step's icon box receives
468586
`ring-2 ring-primary/60 ring-offset-2 ring-offset-background`. This is
469587
the only place 2 px borders are sanctioned (§2.5).
470588

589+
### Child workflow node (`ChildWorkflowNode`)
590+
591+
Steps that spawned their own `WorkflowExecution` (parallel children
592+
materialised as separate runs, plus `call_workflow` / `start_workflow`
593+
sub-workflows) render with the **exact same 200 × 56 card as `StepNode`**
594+
— same footprint, same status palette, same row rhythm — so they never
595+
break the timeline. The **only** difference is the drill-in signature:
596+
597+
- **Stacked sheet** — a second, neutral card peeking ~6 px behind the top
598+
edge (`absolute -top-1.5 left-1.5`, `border-border/55 bg-card/70`). This
599+
is the one motif reserved for "a whole workflow is nested inside here";
600+
nothing else in the graph stacks. It lifts 2 px on hover. It stays
601+
neutral so status color is carried by the front card alone. The sheet
602+
only peeks a few px beyond the dagre box, so the node keeps the cell
603+
footprint (no special-casing in `graph-layout.ts`).
604+
- **Fork icon** (`GitFork`) in the status-tinted tile instead of `Box`.
605+
- **Meta line** leads with `sub-workflow`, then `status · duration` — same
606+
grammar as `StepNode`'s meta line.
607+
- **Drill-in chevron** (`ChevronRight text-primary/70`) before the status
608+
dot.
609+
610+
No input-preview line and no footer: a 56 px card can't carry them
611+
legibly, and the parent step's input/output already live one click away in
612+
the inspector. The whole card is the click target — it fires the same
613+
`durable:step-clicked` event as `StepNode`; the FlowGraph LC's
614+
`handle_event/3` routes it (parallel children → navigate to the child's
615+
flow page; in-process children → open the inspector with the Child tab).
616+
471617
### Drill-in arrow + child preview
472618

473-
A step with an associated child workflow gets a small chevron badge at
474-
the top-right corner of the icon box. Two cases:
619+
`StepNode` (the regular 88 × 96 cell) keeps the small chevron badge at
620+
the top-right of the icon box for the rare case a regular step carries
621+
a `child_workflow_id` but isn't a child-workflow node — fallback only;
622+
prefer the `ChildWorkflowNode` variant. Two routing cases:
475623

476624
1. **Parallel-child step** — the child runs in its own
477625
`WorkflowExecution` with its own steps. Clicking the step navigates
@@ -498,7 +646,106 @@ The step→child mapping is sourced from the parent's context:
498646

499647
---
500648

501-
## 12. Updating this doc
649+
## 12. Information architecture
650+
651+
The dashboard distinguishes **workflow definitions** (registered
652+
modules) from **executions** (individual runs). Routes follow the
653+
nouns:
654+
655+
| URL | LiveView | Purpose |
656+
| -------------------------------- | ---------------- | ---------------------------------------- |
657+
| `/` | `OverviewLive` | Instance KPIs + recent executions |
658+
| `/workflows` | `WorkflowsLive` | Catalog of distinct workflow definitions |
659+
| `/workflows/:name` | `ExecutionsLive` | Executions filtered to that workflow |
660+
| `/executions` | `ExecutionsLive` | All executions on the instance |
661+
| `/executions/:id[/:tab]` | `WorkflowLive` | Single execution detail (six tabs) |
662+
| `/inputs`, `/schedules`, `/settings` | (own LV) | Operator surfaces |
663+
664+
The `/workflows` page is **derived from execution history**
665+
`Durable.Query.list_workflows/1` runs a single GROUP BY against
666+
`durable.workflow_executions`, since the engine has no compile-time
667+
registry of definitions. Each row is a card showing total runs, a
668+
live-pulsing **running chip** (only rendered when `running_count > 0`),
669+
the last run's relative time, and the last status pill. Clicking a
670+
card patches to the per-workflow `ExecutionsLive`.
671+
672+
### Detail tabs
673+
674+
`WorkflowLive` renders six tabs: **Summary, Flow, Timeline, Logs, I/O,
675+
History** (plus a conditional **Family** when the execution has a
676+
parent or descendants).
677+
678+
### Timeline tab
679+
680+
An **absolute-time Gantt** spanning the workflow's wall-clock window —
681+
modelled on Temporal's Event History timeline. Pure HEEx + CSS, no
682+
SVG, no React island. Composition:
683+
684+
- **200-pixel left gutter** per row: status pill (canonical palette
685+
per §4), display name (parallel/branch macro qualifier stripped to
686+
match the Flow tab labels), `×N` retry chip, right-aligned mono
687+
total active duration for the row.
688+
- **Track** (flex-1): one absolutely-positioned segment per
689+
`step_execution`, at `left: <pct>%; width: max(6px, <pct>%)` of
690+
the workflow window. The 6 px floor keeps 1ms bars readable even
691+
inside a 28s window — Temporal does the same.
692+
- **Multiple segments per row** mean the step suspended and resumed
693+
(durable's wait/resume model writes one `step_exec` for the
694+
suspension marker and another for the resumed body). Suspensions
695+
read as the literal gap between segments — no separate annotation.
696+
Retries appear the same way.
697+
- **Tick axis** uses adaptive intervals from a curated nice list
698+
(1 ms → 24 h). Aim ~6 ticks; pick the smallest interval whose
699+
tick count fits the window. The 0% tick suppresses its left
700+
border so it doesn't draw outside the card.
701+
702+
#### Window calculation
703+
704+
- `window_start` = earliest `started_at` across all step_execs.
705+
- `window_end`:
706+
- `now` when `workflow_live?(workflow)` is true (live workflow).
707+
Driven off the *workflow's* status, not step statuses, because
708+
durable's wait/resume leaves stale `:waiting` rows around
709+
forever and they would otherwise pull the window forward
710+
indefinitely.
711+
- Otherwise `max(completed_at || updated_at || started_at)` per
712+
step_exec. A finished workflow viewed an hour later still
713+
shows its original wall-clock duration.
714+
715+
#### `:waiting` step_exec end time
716+
717+
The suspension marker durable writes before a `wait_for_event` /
718+
`sleep` body throws has `status = :waiting, completed_at = nil` and
719+
sticks around forever after the workflow resumes (a *new* step_exec
720+
is written for the resumed body). The Timeline ends those segments
721+
at `step_exec.updated_at` (the moment the row was flipped to
722+
`:waiting`), never `now` — so they collapse to ~6 px instead of
723+
stretching across the window.
724+
725+
#### Realtime
726+
727+
`WorkflowLive` ticks `assigns.now` every 500 ms while
728+
`workflow_in_flight?/1` is true. Each tick re-renders the Timeline
729+
tab; in-flight bars (`:running` segments without `completed_at`)
730+
recompute their width against `now`, transition over 150 ms for
731+
smooth growth, pulse via `animate-pulse`, and carry a led-dot at
732+
their right edge. The clock stops ticking when nothing is in
733+
flight, so idle dashboards don't burn cycles.
734+
735+
#### Why an absolute-time Gantt rather than left-stacked bars
736+
737+
A left-stacked layout (each row's bar starting at `x = 0`) makes
738+
parallel children indistinguishable from sequential children — three
739+
parallel scans starting at the same wall-clock time would look like
740+
they ran one after another. The absolute-time positioning is what
741+
makes parallel-vs-sequential read at a glance.
742+
743+
The Timeline is the analytical view; the History tab remains the
744+
chronological list with expandable input/output details. They are
745+
not duplicates — Timeline is for *durations + ordering*, History is
746+
for *per-step inspection*.
747+
748+
## 13. Updating this doc
502749

503750
When you ship a UI change that establishes new precedent (a new
504751
component, a new color use, a new animation), update this doc in the same

0 commit comments

Comments
 (0)