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>
|
<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> » {{.ID}}</h1>
|
<h1><a href="..">Tasks</a> » {{.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}}—{{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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user