Monorepo for Tangled 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>

oppi.li c1b62153 7ba14966

verified
Changed files
+90 -19
appview
pages
templates
pipelines
+12
appview/pages/pages.go
··· 15 15 "path/filepath" 16 16 "strings" 17 17 "sync" 18 + "time" 18 19 19 20 "tangled.org/core/api/tangled" 20 21 "tangled.org/core/appview/commitverify" ··· 1360 1361 Name string 1361 1362 Command string 1362 1363 Collapsed bool 1364 + StartTime time.Time 1363 1365 } 1364 1366 1365 1367 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1366 1368 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1369 + } 1370 + 1371 + type LogBlockEndParams struct { 1372 + Id int 1373 + StartTime time.Time 1374 + EndTime time.Time 1375 + } 1376 + 1377 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1378 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1367 1379 } 1368 1380 1369 1381 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 2 <div id="lines" hx-swap-oob="beforeend"> 3 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 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> 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> 11 7 </summary> 12 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> 13 9 </details> 14 10 </div> 15 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 15 {{ block "logs" . }} {{ end }} 16 16 </div> 17 17 </section> 18 + {{ template "fragments/workflow-timers" }} 18 19 {{ end }} 19 20 20 21 {{ define "sidebar" }}
+25 -13
appview/pipelines/pipelines.go
··· 227 227 // start a goroutine to read from spindle 228 228 go readLogs(spindleConn, evChan) 229 229 230 - stepIdx := 0 230 + stepStartTimes := make(map[int]time.Time) 231 231 var fragment bytes.Buffer 232 232 for { 233 233 select { ··· 259 259 260 260 switch logLine.Kind { 261 261 case spindlemodel.LogKindControl: 262 - // control messages create a new step block 263 - stepIdx++ 264 - collapsed := false 265 - if logLine.StepKind == spindlemodel.StepKindSystem { 266 - collapsed = true 262 + switch logLine.StepStatus { 263 + case spindlemodel.StepStatusStart: 264 + stepStartTimes[logLine.StepId] = logLine.Time 265 + collapsed := false 266 + if logLine.StepKind == spindlemodel.StepKindSystem { 267 + collapsed = true 268 + } 269 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 270 + Id: logLine.StepId, 271 + Name: logLine.Content, 272 + Command: logLine.StepCommand, 273 + Collapsed: collapsed, 274 + StartTime: logLine.Time, 275 + }) 276 + case spindlemodel.StepStatusEnd: 277 + startTime := stepStartTimes[logLine.StepId] 278 + endTime := logLine.Time 279 + err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 280 + Id: logLine.StepId, 281 + StartTime: startTime, 282 + EndTime: endTime, 283 + }) 267 284 } 268 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 269 - Id: stepIdx, 270 - Name: logLine.Content, 271 - Command: logLine.StepCommand, 272 - Collapsed: collapsed, 273 - }) 285 + 274 286 case spindlemodel.LogKindData: 275 287 // data messages simply insert new log lines into current step 276 288 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 277 - Id: stepIdx, 289 + Id: logLine.StepId, 278 290 Content: logLine.Content, 279 291 }) 280 292 }