appview/pages: show live updating counters for steps #731

merged
opened by oppi.li targeting master from push-lstutzylzylk

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

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 { 1371 Id int 1372 Content string
··· 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 { 1383 Id int 1384 Content string
+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 }