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 "path/filepath" 16 "strings" 17 "sync" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" ··· 1360 Name string 1361 Command string 1362 Collapsed bool 1363 } 1364 1365 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1366 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1367 } 1368 1369 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" ··· 1361 Name string 1362 Command string 1363 Collapsed bool 1364 + StartTime time.Time 1365 } 1366 1367 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 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) 1379 } 1380 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 <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
··· 227 // start a goroutine to read from spindle 228 go readLogs(spindleConn, evChan) 229 230 - stepIdx := 0 231 var fragment bytes.Buffer 232 for { 233 select { ··· 259 260 switch logLine.Kind { 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 267 } 268 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 269 - Id: stepIdx, 270 - Name: logLine.Content, 271 - Command: logLine.StepCommand, 272 - Collapsed: collapsed, 273 - }) 274 case spindlemodel.LogKindData: 275 // data messages simply insert new log lines into current step 276 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 277 - Id: stepIdx, 278 Content: logLine.Content, 279 }) 280 }
··· 227 // start a goroutine to read from spindle 228 go readLogs(spindleConn, evChan) 229 230 + stepStartTimes := make(map[int]time.Time) 231 var fragment bytes.Buffer 232 for { 233 select { ··· 259 260 switch logLine.Kind { 261 case spindlemodel.LogKindControl: 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 + }) 284 } 285 + 286 case spindlemodel.LogKindData: 287 // data messages simply insert new log lines into current step 288 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 289 + Id: logLine.StepId, 290 Content: logLine.Content, 291 }) 292 }