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
···
"path/filepath"
"strings"
"sync"
+
"time"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/commitverify"
···
Name string
Command string
Collapsed bool
+
StartTime time.Time
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
+
type LogBlockEndParams struct {
+
Id int
+
StartTime time.Time
+
EndTime time.Time
+
}
+
+
func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
+
return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
+
}
+
type LogLineParams struct {
Id int
Content string
+36
appview/pages/templates/fragments/workflow-timers.html
···
+
{{ define "fragments/workflow-timers" }}
+
<script>
+
function formatElapsed(seconds) {
+
if (seconds < 1) return '0s';
+
if (seconds < 60) return `${seconds}s`;
+
const minutes = Math.floor(seconds / 60);
+
const secs = seconds % 60;
+
if (seconds < 3600) return `${minutes}m ${secs}s`;
+
const hours = Math.floor(seconds / 3600);
+
const mins = Math.floor((seconds % 3600) / 60);
+
return `${hours}h ${mins}m`;
+
}
+
+
function updateTimers() {
+
const now = Math.floor(Date.now() / 1000);
+
+
document.querySelectorAll('[data-timer]').forEach(el => {
+
const startTime = parseInt(el.dataset.start);
+
const endTime = el.dataset.end ? parseInt(el.dataset.end) : null;
+
+
if (endTime) {
+
// Step is complete, show final time
+
const elapsed = endTime - startTime;
+
el.textContent = formatElapsed(elapsed);
+
} else {
+
// Step is running, update live
+
const elapsed = now - startTime;
+
el.textContent = formatElapsed(elapsed);
+
}
+
});
+
}
+
+
setInterval(updateTimers, 1000);
+
updateTimers();
+
</script>
+
{{ end }}
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
<div id="lines" hx-swap-oob="beforeend">
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
<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">
-
<div class="group-open:hidden flex items-center gap-1">
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
-
</div>
-
<div class="hidden group-open:flex items-center gap-1">
-
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
-
</div>
+
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
+
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
</summary>
<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>
</details>
</div>
{{ end }}
+
+
{{ define "stepHeader" }}
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
+
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
+
{{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
···
+
{{ define "repo/pipelines/fragments/logBlockEnd" }}
+
<span
+
class="ml-auto text-sm text-gray-500 tabular-nums"
+
data-timer="{{ .Id }}"
+
data-start="{{ .StartTime.Unix }}"
+
data-end="{{ .EndTime.Unix }}"
+
hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span>
+
{{ end }}
+
+1
appview/pages/templates/repo/pipelines/workflow.html
···
{{ block "logs" . }} {{ end }}
</div>
</section>
+
{{ template "fragments/workflow-timers" }}
{{ end }}
{{ define "sidebar" }}
+25 -13
appview/pipelines/pipelines.go
···
// start a goroutine to read from spindle
go readLogs(spindleConn, evChan)
-
stepIdx := 0
+
stepStartTimes := make(map[int]time.Time)
var fragment bytes.Buffer
for {
select {
···
switch logLine.Kind {
case spindlemodel.LogKindControl:
-
// control messages create a new step block
-
stepIdx++
-
collapsed := false
-
if logLine.StepKind == spindlemodel.StepKindSystem {
-
collapsed = true
+
switch logLine.StepStatus {
+
case spindlemodel.StepStatusStart:
+
stepStartTimes[logLine.StepId] = logLine.Time
+
collapsed := false
+
if logLine.StepKind == spindlemodel.StepKindSystem {
+
collapsed = true
+
}
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
+
Id: logLine.StepId,
+
Name: logLine.Content,
+
Command: logLine.StepCommand,
+
Collapsed: collapsed,
+
StartTime: logLine.Time,
+
})
+
case spindlemodel.StepStatusEnd:
+
startTime := stepStartTimes[logLine.StepId]
+
endTime := logLine.Time
+
err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{
+
Id: logLine.StepId,
+
StartTime: startTime,
+
EndTime: endTime,
+
})
}
-
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
-
Id: stepIdx,
-
Name: logLine.Content,
-
Command: logLine.StepCommand,
-
Collapsed: collapsed,
-
})
+
case spindlemodel.LogKindData:
// data messages simply insert new log lines into current step
err = p.pages.LogLine(&fragment, pages.LogLineParams{
-
Id: stepIdx,
+
Id: logLine.StepId,
Content: logLine.Content,
})
}