11defmodule 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"
0 commit comments