Refresh task view dynamically with Javascript
All checks were successful
Alpine 3.20 Success

This is more efficient, responsive, and user friendly.
This commit is contained in:
Přemysl Eric Janouch 2024-12-26 18:41:29 +01:00
parent 4f2c2dc8da
commit d83517f67b
Signed by: p
GPG Key ID: A0420B94F92B9493
3 changed files with 220 additions and 55 deletions

231
acid.go
View File

@ -263,53 +263,182 @@ var templateTask = template.Must(template.New("tasks").Parse(`
<head> <head>
<title>Task {{.ID}}</title> <title>Task {{.ID}}</title>
<meta charset="utf-8"> <meta charset="utf-8">
{{if .IsRunning}}
<meta http-equiv="refresh" content="5">
{{end}}
</head> </head>
<body> <body>
<h1><a href="..">Tasks</a> &raquo; {{.ID}}</h1> <h1><a href="..">Tasks</a> &raquo; {{.ID}}</h1>
<dl> <dl>
{{if .Created}} <!-- Remember to synchronise these lists with Javascript updates. -->
{{if .Created -}}
<dt>Created</dt> <dt>Created</dt>
<dd><span title="{{.Created}}">{{.CreatedAgo}} ago</span></dd> <dd><span id="created" title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
{{end}} {{end -}}
{{if .Changed}} {{if .Changed -}}
<dt>Changed</dt> <dt>Changed</dt>
<dd><span title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd> <dd><span id="changed" title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
{{end}} {{end -}}
<dt>Project</dt> <dt>Project</dt>
<dd><a href="{{.RepoURL}}">{{.FullName}}</a></dd> <dd id="project"><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
<dt>Commit</dt> <dt>Commit</dt>
<dd><a href="{{.CommitURL}}">{{.Hash}}</a></dd> <dd id="commit"><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
<dt>Runner</dt> <dt>Runner</dt>
<dd>{{.RunnerName}}</dd> <dd id="runner">{{.RunnerName}}</dd>
<dt>State</dt> <dt>State</dt>
<dd>{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd> <dd id="state">{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
<dt>Notified</dt> <dt>Notified</dt>
<dd>{{.Notified}}</dd> <dd id="notified">{{.Notified}}</dd>
{{if .Duration}}
<dt>Duration</dt> <dt>Duration</dt>
<dd>{{.Duration}}</dd> <dd id="duration">{{if .Duration}}{{.Duration}}{{else}}&mdash;{{end}}</dd>
{{end}}
</dl> </dl>
{{if .RunLog}}
<h2>Runner log</h2> <h2 id="run"{{if not .RunLog}} hidden{{end}}>Runner log</h2>
<pre>{{printf "%s" .RunLog}}</pre> <pre id="runlog"{{if not .RunLog}} hidden{{
{{end}} end}}>{{printf "%s" .RunLog}}</pre>
{{if .TaskLog}} <h2 id="task"{{if not .TaskLog}} hidden{{end}}>Task log</h2>
<h2>Task log</h2> <pre id="tasklog"{{if not .TaskLog}} hidden{{
<pre>{{printf "%s" .TaskLog}}</pre> end}}>{{printf "%s" .TaskLog}}</pre>
{{end}} <h2 id="deploy"{{if not .DeployLog}} hidden{{end}}>Deploy log</h2>
{{if .DeployLog}} <pre id="deploylog"{{if not .DeployLog}} hidden{{
<h2>Deploy log</h2> end}}>{{printf "%s" .DeployLog}}</pre>
<pre>{{printf "%s" .DeployLog}}</pre>
{{end}} {{if .IsRunning -}}
</table> <script>
function get(id) {
return document.getElementById(id)
}
function getLog(id) {
const header = document.getElementById(id)
const log = document.getElementById(id + 'log')
const text = log.textContent
// lines[-1] is an implementation detail of terminalWriter.Serialize,
// lines[-2] is the actual last line.
const last = Math.max(0, text.split('\n').length - 2)
return {header, log, text, last}
}
function refreshLog(log, top, changed) {
if (top <= 0)
log.log.textContent = changed
else
log.log.textContent =
log.text.split('\n').slice(0, top).join('\n') + '\n' + changed
const empty = log.log.textContent === ''
log.header.hidden = empty
log.log.hidden = empty
}
let refresher = setInterval(() => {
let run = getLog('run'), task = getLog('task'), deploy = getLog('deploy')
const url = new URL(window.location.href)
url.search = ''
url.searchParams.set('json', '')
url.searchParams.set('run', run.last)
url.searchParams.set('task', task.last)
url.searchParams.set('deploy', deploy.last)
fetch(url.toString()).then(response => {
if (!response.ok)
throw response.statusText
return response.json()
}).then(data => {
const scroll = window.scrollY + window.innerHeight
>= document.documentElement.scrollHeight
if (data.Created) {
get('created').title = data.Created
get('created').textContent = data.CreatedAgo + " ago"
}
if (data.Changed) {
get('changed').title = data.Changed
get('changed').textContent = data.ChangedAgo + " ago"
}
get('state').textContent = data.State
if (data.Detail !== '')
get('state').textContent += " (" + data.Detail + ")"
get('notified').textContent = String(data.Notified)
if (data.Duration)
get('duration').textContent = data.Duration
refreshLog(run, data.RunLogTop, data.RunLog)
refreshLog(task, data.TaskLogTop, data.TaskLog)
refreshLog(deploy, data.DeployLogTop, data.DeployLog)
if (scroll)
document.documentElement.scrollTop =
document.documentElement.scrollHeight
if (!data.IsRunning)
clearInterval(refresher)
}).catch(error => {
alert(error)
clearInterval(refresher)
})
}, 1000 /* For faster updates than this, we should use WebSockets. */)
</script>
{{end -}}
</body> </body>
</html> </html>
`)) `))
// handlerTask serves as the data for JSON encoding and the task HTML template.
// It needs to explicitly include many convenience method results.
type handlerTask struct {
Task
IsRunning bool
Created *string // Task.Created?.String()
Changed *string // Task.Changed?.String()
CreatedAgo string // Task.CreatedAgo()
ChangedAgo string // Task.ChangedAgo()
Duration *string // Task.Duration?.String()
State string // Task.State.String()
RunLog string
RunLogTop int
TaskLog string
TaskLogTop int
DeployLog string
DeployLogTop int
}
func toNilableString[T fmt.Stringer](stringer *T) *string {
if stringer == nil {
return nil
}
s := (*stringer).String()
return &s
}
func newHandlerTask(task Task) handlerTask {
return handlerTask{
Task: task,
RunLog: string(task.RunLog),
TaskLog: string(task.TaskLog),
DeployLog: string(task.DeployLog),
Created: toNilableString(task.Created()),
Changed: toNilableString(task.Changed()),
CreatedAgo: task.CreatedAgo(),
ChangedAgo: task.ChangedAgo(),
Duration: toNilableString(task.Duration()),
State: task.State.String(),
}
}
func (ht *handlerTask) updateFromRunning(
rt *RunningTask, lastRun, lastTask, lastDeploy int) {
ht.IsRunning = true
ht.Task.DurationSeconds = rt.elapsed()
ht.Duration = toNilableString(ht.Task.Duration())
rt.RunLog.Lock()
defer rt.RunLog.Unlock()
rt.TaskLog.Lock()
defer rt.TaskLog.Unlock()
rt.DeployLog.Lock()
defer rt.DeployLog.Unlock()
ht.RunLog, ht.RunLogTop = rt.RunLog.SerializeUpdates(lastRun)
ht.TaskLog, ht.TaskLogTop = rt.TaskLog.SerializeUpdates(lastTask)
ht.DeployLog, ht.DeployLogTop = rt.DeployLog.SerializeUpdates(lastDeploy)
}
func handleTask(w http.ResponseWriter, r *http.Request) { func handleTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id")) id, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {
@ -330,36 +459,32 @@ func handleTask(w http.ResponseWriter, r *http.Request) {
return return
} }
task := struct { // These are intended for running tasks,
Task // so don't reprocess DB logs, which would only help the last update.
IsRunning bool q := r.URL.Query()
}{Task: tasks[0]} lastRun, _ := strconv.Atoi(q.Get("run"))
lastTask, _ := strconv.Atoi(q.Get("task"))
lastDeploy, _ := strconv.Atoi(q.Get("deploy"))
task := newHandlerTask(tasks[0])
func() { func() {
gRunningMutex.Lock() gRunningMutex.Lock()
defer gRunningMutex.Unlock() defer gRunningMutex.Unlock()
rt, ok := gRunning[task.ID] if rt, ok := gRunning[task.ID]; ok {
task.IsRunning = ok task.updateFromRunning(
if !ok { rt, int(lastRun), int(lastTask), int(lastDeploy))
return
} }
task.DurationSeconds = rt.elapsed()
rt.RunLog.Lock()
defer rt.RunLog.Unlock()
rt.TaskLog.Lock()
defer rt.TaskLog.Unlock()
rt.DeployLog.Lock()
defer rt.DeployLog.Unlock()
task.RunLog = rt.RunLog.Serialize(0)
task.TaskLog = rt.TaskLog.Serialize(0)
task.DeployLog = rt.DeployLog.Serialize(0)
}() }()
if err := templateTask.Execute(w, &task); err != nil { if q.Has("json") {
http.Error(w, err.Error(), http.StatusInternalServerError) w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&task)
} else {
err = templateTask.Execute(w, &task)
}
if err != nil {
log.Println(err)
} }
} }

View File

@ -47,6 +47,16 @@ func (tw *terminalWriter) log(format string, v ...interface{}) {
} }
} }
// SerializeUpdates returns an update block for a client with a given last line,
// and the index of the first line in the update block.
func (tw *terminalWriter) SerializeUpdates(last int) (string, int) {
if last < 0 || last >= len(tw.lines) {
return "", last
}
top := tw.lines[last].updateGroup
return string(tw.Serialize(top)), top
}
func (tw *terminalWriter) Serialize(top int) []byte { func (tw *terminalWriter) Serialize(top int) []byte {
var b bytes.Buffer var b bytes.Buffer
for i := top; i < len(tw.lines); i++ { for i := top; i < len(tw.lines); i++ {
@ -104,7 +114,7 @@ func (tw *terminalWriter) processPrint(r rune) {
// Refresh update trackers, if necessary. // Refresh update trackers, if necessary.
if tw.lines[len(tw.lines)-1].updateGroup > tw.line { if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
for i := tw.line; i < len(tw.lines); i++ { for i := tw.line; i < len(tw.lines); i++ {
tw.lines[i].updateGroup = tw.line tw.lines[i].updateGroup = min(tw.lines[i].updateGroup, tw.line)
} }
} }

View File

@ -1,6 +1,9 @@
package main package main
import "testing" import (
"slices"
"testing"
)
// This could be way more extensive, but we're not aiming for perfection. // This could be way more extensive, but we're not aiming for perfection.
var tests = []struct { var tests = []struct {
@ -68,3 +71,30 @@ Loop:
} }
} }
} }
func TestTerminalUpdateGroups(t *testing.T) {
tw := terminalWriter{}
collect := func() (have []int) {
for _, line := range tw.lines {
have = append(have, line.updateGroup)
}
return
}
// 0: A 0 0 0
// 1: B X 1 1 1
// 2: C Y 1 2 1 1
// 3: Z 2 3 2
// 4: 3 4
tw.Write([]byte("A\nB\nC\x1b[FX\nY\nZ"))
have, want := collect(), []int{0, 1, 1, 3}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
tw.Write([]byte("\x1b[F1\n2\n3"))
have, want = collect(), []int{0, 1, 1, 2, 4}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
}