Skip to content

Commit 7ea5c1c

Browse files
committed
feat(dashboard): workflow detail tabs
Summary, Logs, Timeline (absolute-time Gantt with frozen gutter), History (vertical trace spine) and I/O tabs sharing LogLine/StepDetail; the workflow_live tab shell.
1 parent d6f2f9a commit 7ea5c1c

7 files changed

Lines changed: 974 additions & 198 deletions

File tree

durable_dashboard/lib/durable_dashboard/components/workflow/history_tab.ex

Lines changed: 114 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
defmodule DurableDashboard.Components.Workflow.HistoryTab do
22
@moduledoc """
3-
History tab — chronological step execution timeline. Each row shows the
4-
step name, status, attempt, duration, and an expandable details block
5-
with input/output/error if present.
3+
History tab — the workflow's chronological execution trace. Every step
4+
attempt is one event in order (so a failure and its retry appear as two
5+
rows), threaded on a status-colored spine down the left edge: the sequence
6+
is the information here, so it reads as a trace, not a flat table.
7+
8+
Each event is a native `<details>` — click the row to expand the shared
9+
`StepDetail` panel (timing, I/O, error, logs) inline. No LiveComponent
10+
state needed; the browser owns the open/closed disclosure.
611
"""
712

813
use Phoenix.Component
914

1015
alias DurableDashboard.Components.Core
16+
alias DurableDashboard.Components.Workflow.StepDetail
1117

1218
attr :steps, :list, required: true
1319

1420
def history_tab(assigns) do
21+
assigns = assign(assigns, :count, length(assigns.steps))
22+
1523
~H"""
1624
<%= if @steps == [] do %>
1725
<Core.empty_state
@@ -20,92 +28,131 @@ defmodule DurableDashboard.Components.Workflow.HistoryTab do
2028
description="Step executions appear here as they run."
2129
/>
2230
<% else %>
23-
<Core.card padding="none">
24-
<ol class="divide-y divide-border">
25-
<li :for={step <- @steps} class="px-4 py-3">
26-
<.step_row step={step} />
27-
</li>
31+
<Core.card padding="md">
32+
<:title>Execution trace</:title>
33+
<:action>
34+
<span class="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
35+
{@count} {if @count == 1, do: "event", else: "events"}
36+
</span>
37+
</:action>
38+
39+
<ol class="relative">
40+
<% last = @count - 1 %>
41+
<.event_row
42+
:for={{step, i} <- Enum.with_index(@steps)}
43+
step={step}
44+
first?={i == 0}
45+
last?={i == last}
46+
/>
2847
</ol>
2948
</Core.card>
3049
<% end %>
3150
"""
3251
end
3352

3453
attr :step, :map, required: true
54+
attr :first?, :boolean, required: true
55+
attr :last?, :boolean, required: true
56+
57+
defp event_row(assigns) do
58+
status = to_string(assigns.step.status)
59+
assigns = assign(assigns, status: status)
3560

36-
defp step_row(assigns) do
3761
~H"""
38-
<div class="flex items-start gap-3">
39-
<div class="pt-0.5 shrink-0">
40-
<Core.status_pill status={@step.status} />
62+
<li class="relative flex gap-3">
63+
<%!-- Spine rail: a continuous vertical line with a status node bead.
64+
The connector is trimmed at the first/last events so it doesn't
65+
dangle past the ends of the trace. --%>
66+
<div class="relative flex w-3 shrink-0 justify-center">
67+
<span class={[
68+
"absolute left-1/2 w-px -translate-x-1/2 bg-border",
69+
connector_class(@first?, @last?)
70+
]}>
71+
</span>
72+
<span class={[
73+
"relative z-10 mt-3.5 size-2.5 rounded-full ring-4 ring-card",
74+
node_tone(@status),
75+
pulsing?(@status) && "led-dot"
76+
]}>
77+
</span>
4178
</div>
4279
43-
<div class="flex flex-col gap-1 min-w-0 flex-1">
44-
<div class="flex items-baseline justify-between gap-3">
45-
<div class="flex items-center gap-2 min-w-0">
46-
<Core.code class="font-medium">{@step.step_name}</Core.code>
47-
<span :if={@step.step_type} class="text-[11px] text-muted-foreground uppercase tracking-wider">
48-
{@step.step_type}
49-
</span>
50-
<span :if={@step.attempt > 1} class="text-[11px] text-warning">
51-
attempt {@step.attempt}
52-
</span>
53-
</div>
54-
<div class="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
55-
<span :if={@step.duration_ms} class="text-numeric">
80+
<details class="group/event min-w-0 flex-1 border-b border-border/50 last:border-0">
81+
<summary class={[
82+
"-mx-1 flex cursor-pointer list-none items-center gap-2.5 rounded-md px-1 py-2.5",
83+
"hover:bg-accent/20 [&::-webkit-details-marker]:hidden"
84+
]}>
85+
<Core.code class="font-medium">{@step.step_name}</Core.code>
86+
<span class={["text-[11px] font-medium", status_text_tone(@status)]}>{@status}</span>
87+
<span
88+
:if={@step.attempt > 1}
89+
class="inline-flex items-center gap-1 rounded-full bg-warning/10 px-1.5 py-0.5 font-mono text-[10px] text-warning"
90+
title={"Retry — attempt #{@step.attempt}"}
91+
>
92+
<Core.icon name="arrow-path" class="size-2.5" /> retry {@step.attempt}
93+
</span>
94+
95+
<span class="ml-auto flex shrink-0 items-center gap-3">
96+
<span
97+
:if={@step.duration_ms}
98+
class="font-mono text-[11px] tabular-nums text-muted-foreground"
99+
>
56100
{format_duration(@step.duration_ms)}
57101
</span>
58-
<Core.relative_time at={@step.inserted_at} />
59-
</div>
60-
</div>
61-
62-
<details :if={has_details?(@step)} class="text-xs mt-1">
63-
<summary class="cursor-pointer text-muted-foreground hover:text-foreground">
64-
Details
65-
</summary>
66-
<div class="mt-2 space-y-2 pl-3 border-l border-border">
67-
<.detail_block :if={@step.input} label="Input" payload={@step.input} />
68-
<.detail_block :if={@step.output} label="Output" payload={@step.output} />
69-
<.detail_block :if={@step.error} label="Error" payload={@step.error} variant="error" />
70-
</div>
71-
</details>
72-
</div>
73-
</div>
102+
<Core.relative_time at={@step.inserted_at} class="text-muted-foreground/55" />
103+
<Core.icon
104+
name="chevron-right"
105+
class="size-3.5 text-muted-foreground/40 transition-transform group-open/event:rotate-90"
106+
/>
107+
</span>
108+
</summary>
109+
110+
<StepDetail.panel step={@step} class="mt-1 mb-3" />
111+
</details>
112+
</li>
74113
"""
75114
end
76115

77-
attr :label, :string, required: true
78-
attr :payload, :any, required: true
79-
attr :variant, :string, default: "default"
116+
# The connector segment between this node and its neighbours. Full height in
117+
# the middle of the trace; trimmed to half at the first/last events; hidden
118+
# when there's only one.
119+
defp connector_class(true, true), do: "hidden"
120+
defp connector_class(true, false), do: "top-4 bottom-0"
121+
defp connector_class(false, true), do: "top-0 h-4"
122+
defp connector_class(_, _), do: "inset-y-0"
80123

81-
defp detail_block(assigns) do
82-
~H"""
83-
<div>
84-
<span class={[
85-
"block text-[10px] uppercase tracking-wider mb-1",
86-
if(@variant == "error", do: "text-destructive", else: "text-muted-foreground")
87-
]}>
88-
{@label}
89-
</span>
90-
<pre class={[
91-
"text-[11px] font-mono leading-relaxed thin-scroll",
92-
"px-3 py-2 max-h-48 overflow-auto rounded-md",
93-
"bg-muted/30 text-foreground/90 whitespace-pre-wrap"
94-
]}><code>{pretty(@payload)}</code></pre>
95-
</div>
96-
"""
97-
end
124+
defp status_kind(s) when is_atom(s), do: status_kind(to_string(s))
125+
defp status_kind("completed"), do: "success"
126+
defp status_kind("running"), do: "success"
127+
defp status_kind("waiting"), do: "warning"
128+
defp status_kind("compensating"), do: "warning"
129+
defp status_kind("failed"), do: "destructive"
130+
defp status_kind("timeout"), do: "destructive"
131+
defp status_kind("scheduled"), do: "info"
132+
defp status_kind(_), do: "muted"
98133

99-
defp has_details?(step) do
100-
not is_nil(step.input) or not is_nil(step.output) or not is_nil(step.error)
134+
defp node_tone(s) do
135+
case status_kind(s) do
136+
"success" -> "bg-success"
137+
"warning" -> "bg-warning"
138+
"destructive" -> "bg-destructive"
139+
"info" -> "bg-info"
140+
_ -> "bg-muted-foreground/50"
141+
end
101142
end
102143

103-
defp pretty(payload) do
104-
Jason.encode!(payload, pretty: true)
105-
rescue
106-
_ -> inspect(payload, pretty: true, limit: :infinity)
144+
defp status_text_tone(s) do
145+
case status_kind(s) do
146+
"success" -> "text-success"
147+
"warning" -> "text-warning"
148+
"destructive" -> "text-destructive"
149+
"info" -> "text-info"
150+
_ -> "text-muted-foreground"
151+
end
107152
end
108153

154+
defp pulsing?(s), do: to_string(s) in ["running", "waiting", "compensating"]
155+
109156
defp format_duration(nil), do: "—"
110157
defp format_duration(ms) when ms < 1000, do: "#{ms}ms"
111158
defp format_duration(ms) when ms < 60_000, do: "#{Float.round(ms / 1000, 1)}s"

durable_dashboard/lib/durable_dashboard/components/workflow/io_tab.ex

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,7 @@ defmodule DurableDashboard.Components.Workflow.IoTab do
4848
{@empty}
4949
</div>
5050
<% else %>
51-
<pre class={[
52-
"text-xs font-mono leading-relaxed",
53-
"px-4 py-3 max-h-[600px] overflow-auto thin-scroll",
54-
"bg-muted/20 text-foreground/90 whitespace-pre"
55-
]}><code>{pretty(@payload)}</code></pre>
51+
<Core.json value={@payload} bare class="max-h-[600px] bg-muted/20 px-4 py-3" />
5652
<% end %>
5753
"""
5854
end
@@ -63,12 +59,6 @@ defmodule DurableDashboard.Components.Workflow.IoTab do
6359
defp empty?(""), do: true
6460
defp empty?(_), do: false
6561

66-
defp pretty(payload) do
67-
Jason.encode!(payload, pretty: true)
68-
rescue
69-
_ -> inspect(payload, pretty: true, limit: :infinity)
70-
end
71-
7262
defp byte_size_kb(nil), do: "—"
7363

7464
defp byte_size_kb(payload) do

0 commit comments

Comments
 (0)