This is more efficient, responsive, and user friendly.
This commit is contained in:
parent
4f2c2dc8da
commit
d83517f67b
231
acid.go
231
acid.go
@ -263,53 +263,182 @@ var templateTask = template.Must(template.New("tasks").Parse(`
|
||||
<head>
|
||||
<title>Task {{.ID}}</title>
|
||||
<meta charset="utf-8">
|
||||
{{if .IsRunning}}
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="..">Tasks</a> » {{.ID}}</h1>
|
||||
<dl>
|
||||
{{if .Created}}
|
||||
<!-- Remember to synchronise these lists with Javascript updates. -->
|
||||
{{if .Created -}}
|
||||
<dt>Created</dt>
|
||||
<dd><span title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
|
||||
{{end}}
|
||||
{{if .Changed}}
|
||||
<dd><span id="created" title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
|
||||
{{end -}}
|
||||
{{if .Changed -}}
|
||||
<dt>Changed</dt>
|
||||
<dd><span title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
|
||||
{{end}}
|
||||
<dd><span id="changed" title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
|
||||
{{end -}}
|
||||
<dt>Project</dt>
|
||||
<dd><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
|
||||
<dd id="project"><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
|
||||
<dt>Commit</dt>
|
||||
<dd><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
|
||||
<dd id="commit"><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
|
||||
<dt>Runner</dt>
|
||||
<dd>{{.RunnerName}}</dd>
|
||||
<dd id="runner">{{.RunnerName}}</dd>
|
||||
<dt>State</dt>
|
||||
<dd>{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
|
||||
<dd id="state">{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
|
||||
<dt>Notified</dt>
|
||||
<dd>{{.Notified}}</dd>
|
||||
{{if .Duration}}
|
||||
<dd id="notified">{{.Notified}}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{{.Duration}}</dd>
|
||||
{{end}}
|
||||
<dd id="duration">{{if .Duration}}{{.Duration}}{{else}}—{{end}}</dd>
|
||||
</dl>
|
||||
{{if .RunLog}}
|
||||
<h2>Runner log</h2>
|
||||
<pre>{{printf "%s" .RunLog}}</pre>
|
||||
{{end}}
|
||||
{{if .TaskLog}}
|
||||
<h2>Task log</h2>
|
||||
<pre>{{printf "%s" .TaskLog}}</pre>
|
||||
{{end}}
|
||||
{{if .DeployLog}}
|
||||
<h2>Deploy log</h2>
|
||||
<pre>{{printf "%s" .DeployLog}}</pre>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2 id="run"{{if not .RunLog}} hidden{{end}}>Runner log</h2>
|
||||
<pre id="runlog"{{if not .RunLog}} hidden{{
|
||||
end}}>{{printf "%s" .RunLog}}</pre>
|
||||
<h2 id="task"{{if not .TaskLog}} hidden{{end}}>Task log</h2>
|
||||
<pre id="tasklog"{{if not .TaskLog}} hidden{{
|
||||
end}}>{{printf "%s" .TaskLog}}</pre>
|
||||
<h2 id="deploy"{{if not .DeployLog}} hidden{{end}}>Deploy log</h2>
|
||||
<pre id="deploylog"{{if not .DeployLog}} hidden{{
|
||||
end}}>{{printf "%s" .DeployLog}}</pre>
|
||||
|
||||
{{if .IsRunning -}}
|
||||
<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>
|
||||
</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) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
@ -330,36 +459,32 @@ func handleTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
task := struct {
|
||||
Task
|
||||
IsRunning bool
|
||||
}{Task: tasks[0]}
|
||||
// These are intended for running tasks,
|
||||
// so don't reprocess DB logs, which would only help the last update.
|
||||
q := r.URL.Query()
|
||||
lastRun, _ := strconv.Atoi(q.Get("run"))
|
||||
lastTask, _ := strconv.Atoi(q.Get("task"))
|
||||
lastDeploy, _ := strconv.Atoi(q.Get("deploy"))
|
||||
|
||||
task := newHandlerTask(tasks[0])
|
||||
func() {
|
||||
gRunningMutex.Lock()
|
||||
defer gRunningMutex.Unlock()
|
||||
|
||||
rt, ok := gRunning[task.ID]
|
||||
task.IsRunning = ok
|
||||
if !ok {
|
||||
return
|
||||
if rt, ok := gRunning[task.ID]; ok {
|
||||
task.updateFromRunning(
|
||||
rt, int(lastRun), int(lastTask), int(lastDeploy))
|
||||
}
|
||||
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if q.Has("json") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
12
terminal.go
12
terminal.go
@ -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 {
|
||||
var b bytes.Buffer
|
||||
for i := top; i < len(tw.lines); i++ {
|
||||
@ -104,7 +114,7 @@ func (tw *terminalWriter) processPrint(r rune) {
|
||||
// Refresh update trackers, if necessary.
|
||||
if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This could be way more extensive, but we're not aiming for perfection.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user