forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview/pages: show live updating counters for steps

spindles can now give us detailed logs for start and end of steps. the
appview can ingest these logs to indicate live durations for steps. it
is implemented like so:

- the logs handler keeps track of start and end times for each step
- whenever we recieve a start or end time, we update the html to add a
`data-start` or `data-end` attribute
- using some javascript, we print a live updating timer for each step:
* if only `data-start` is present: then use Now - Start and update
each second
* if both `data-start` and `data-end` are present, then use End -
Start

Signed-off-by: oppiliappan <me@oppi.li>

authored by oppi.li and committed by Tangled 5546d45d a7f7a5d2

Changed files
+90 -19
appview
pages
templates
pipelines
+12
appview/pages/pages.go
··· 15 "path/filepath" 16 "strings" 17 "sync" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" ··· 1361 Name string 1362 Command string 1363 Collapsed bool 1364 } 1365 1366 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1367 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1368 } 1369 1370 type LogLineParams struct {
··· 15 "path/filepath" 16 "strings" 17 "sync" 18 + "time" 19 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/commitverify" ··· 1362 Name string 1363 Command string 1364 Collapsed bool 1365 + StartTime time.Time 1366 } 1367 1368 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1369 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1370 + } 1371 + 1372 + type LogBlockEndParams struct { 1373 + Id int 1374 + StartTime time.Time 1375 + EndTime time.Time 1376 + } 1377 + 1378 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1379 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1380 } 1381 1382 type LogLineParams struct {
+36
appview/pages/templates/fragments/workflow-timers.html
···
··· 1 + {{ define "fragments/workflow-timers" }} 2 + <script> 3 + function formatElapsed(seconds) { 4 + if (seconds < 1) return '0s'; 5 + if (seconds < 60) return `${seconds}s`; 6 + const minutes = Math.floor(seconds / 60); 7 + const secs = seconds % 60; 8 + if (seconds < 3600) return `${minutes}m ${secs}s`; 9 + const hours = Math.floor(seconds / 3600); 10 + const mins = Math.floor((seconds % 3600) / 60); 11 + return `${hours}h ${mins}m`; 12 + } 13 + 14 + function updateTimers() { 15 + const now = Math.floor(Date.now() / 1000); 16 + 17 + document.querySelectorAll('[data-timer]').forEach(el => { 18 + const startTime = parseInt(el.dataset.start); 19 + const endTime = el.dataset.end ? parseInt(el.dataset.end) : null; 20 + 21 + if (endTime) { 22 + // Step is complete, show final time 23 + const elapsed = endTime - startTime; 24 + el.textContent = formatElapsed(elapsed); 25 + } else { 26 + // Step is running, update live 27 + const elapsed = now - startTime; 28 + el.textContent = formatElapsed(elapsed); 29 + } 30 + }); 31 + } 32 + 33 + setInterval(updateTimers, 1000); 34 + updateTimers(); 35 + </script> 36 + {{ end }}
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 <div id="lines" hx-swap-oob="beforeend"> 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1"> 6 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 - </div> 8 - <div class="hidden group-open:flex items-center gap-1"> 9 - {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 - </div> 11 </summary> 12 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 </details> 14 </div> 15 {{ end }}
··· 2 <div id="lines" hx-swap-oob="beforeend"> 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 + <div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div> 7 </summary> 8 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 9 </details> 10 </div> 11 {{ end }} 12 + 13 + {{ define "stepHeader" }} 14 + {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 15 + <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 + {{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
··· 1 + {{ define "repo/pipelines/fragments/logBlockEnd" }} 2 + <span 3 + class="ml-auto text-sm text-gray-500 tabular-nums" 4 + data-timer="{{ .Id }}" 5 + data-start="{{ .StartTime.Unix }}" 6 + data-end="{{ .EndTime.Unix }}" 7 + hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span> 8 + {{ end }} 9 +
+1
appview/pages/templates/repo/pipelines/workflow.html
··· 15 {{ block "logs" . }} {{ end }} 16 </div> 17 </section> 18 {{ end }} 19 20 {{ define "sidebar" }}
··· 15 {{ block "logs" . }} {{ end }} 16 </div> 17 </section> 18 + {{ template "fragments/workflow-timers" }} 19 {{ end }} 20 21 {{ define "sidebar" }}
+25 -13
appview/pipelines/pipelines.go
··· 236 // start a goroutine to read from spindle 237 go readLogs(spindleConn, evChan) 238 239 - stepIdx := 0 240 var fragment bytes.Buffer 241 for { 242 select { ··· 268 269 switch logLine.Kind { 270 case spindlemodel.LogKindControl: 271 - // control messages create a new step block 272 - stepIdx++ 273 - collapsed := false 274 - if logLine.StepKind == spindlemodel.StepKindSystem { 275 - collapsed = true 276 } 277 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 278 - Id: stepIdx, 279 - Name: logLine.Content, 280 - Command: logLine.StepCommand, 281 - Collapsed: collapsed, 282 - }) 283 case spindlemodel.LogKindData: 284 // data messages simply insert new log lines into current step 285 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 286 - Id: stepIdx, 287 Content: logLine.Content, 288 }) 289 }
··· 236 // start a goroutine to read from spindle 237 go readLogs(spindleConn, evChan) 238 239 + stepStartTimes := make(map[int]time.Time) 240 var fragment bytes.Buffer 241 for { 242 select { ··· 268 269 switch logLine.Kind { 270 case spindlemodel.LogKindControl: 271 + switch logLine.StepStatus { 272 + case spindlemodel.StepStatusStart: 273 + stepStartTimes[logLine.StepId] = logLine.Time 274 + collapsed := false 275 + if logLine.StepKind == spindlemodel.StepKindSystem { 276 + collapsed = true 277 + } 278 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 279 + Id: logLine.StepId, 280 + Name: logLine.Content, 281 + Command: logLine.StepCommand, 282 + Collapsed: collapsed, 283 + StartTime: logLine.Time, 284 + }) 285 + case spindlemodel.StepStatusEnd: 286 + startTime := stepStartTimes[logLine.StepId] 287 + endTime := logLine.Time 288 + err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 289 + Id: logLine.StepId, 290 + StartTime: startTime, 291 + EndTime: endTime, 292 + }) 293 } 294 + 295 case spindlemodel.LogKindData: 296 // data messages simply insert new log lines into current step 297 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 298 + Id: logLine.StepId, 299 Content: logLine.Content, 300 }) 301 }