Add worker stats to the wizard, artifact pruning and offline-worker removal to the admin console
This commit is contained in:
@@ -289,15 +289,26 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
// statusView is what the wizard needs to paint the running/stopped state.
|
||||
type statusView struct {
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
LogPath string `json:"log_path"`
|
||||
Running bool `json:"running"`
|
||||
Pid int `json:"pid"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
Coordinator string `json:"coordinator,omitempty"`
|
||||
WorkDir string `json:"work_dir,omitempty"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
LogPath string `json:"log_path"`
|
||||
Running bool `json:"running"`
|
||||
Pid int `json:"pid"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
Coordinator string `json:"coordinator,omitempty"`
|
||||
WorkDir string `json:"work_dir,omitempty"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
Stats WorkerStats `json:"stats"`
|
||||
}
|
||||
|
||||
// WorkerStats is parsed from the worker log: the agent reports each claim,
|
||||
// completion and failure as a structured line, so the wizard can show live
|
||||
// counters without any coordinator access.
|
||||
type WorkerStats struct {
|
||||
Registered bool `json:"registered"`
|
||||
Claimed int `json:"claimed"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// ensureVenvTaskRunner rewrites the saved config so its task runner uses the
|
||||
@@ -319,8 +330,30 @@ func (s *Server) ensureVenvTaskRunner() {
|
||||
}
|
||||
}
|
||||
|
||||
// parseWorkerStats counts the structured agent events in the worker log.
|
||||
func parseWorkerStats(logPath string) WorkerStats {
|
||||
var stats WorkerStats
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
return stats
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
switch {
|
||||
case strings.Contains(line, "msg=registered"):
|
||||
stats.Registered = true
|
||||
case strings.Contains(line, `msg="task claimed"`):
|
||||
stats.Claimed++
|
||||
case strings.Contains(line, `msg="task completed"`):
|
||||
stats.Completed++
|
||||
case strings.Contains(line, `msg="task failed"`):
|
||||
stats.Failed++
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
view := statusView{ConfigPath: s.cfgPath, LogPath: s.logPath, Running: s.sup.Alive(), Pid: s.sup.Pid()}
|
||||
view := statusView{ConfigPath: s.cfgPath, LogPath: s.logPath, Running: s.sup.Alive(), Pid: s.sup.Pid(), Stats: parseWorkerStats(s.logPath)}
|
||||
if raw, err := os.ReadFile(s.cfgPath); err == nil {
|
||||
var file agent.ConfigFile
|
||||
if json.Unmarshal(raw, &file) == nil {
|
||||
|
||||
@@ -479,3 +479,21 @@ func TestTestProbesTheVenvPythonAfterInstall(t *testing.T) {
|
||||
t.Errorf("scimesh check = %+v, want the venv interpreter reporting 9.9.9-test", report.Scimesh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkerStats(t *testing.T) {
|
||||
logPath := filepath.Join(t.TempDir(), "worker.log")
|
||||
content := `time=1 level=INFO msg=registered worker_id=w1
|
||||
time=2 level=INFO msg="task claimed" task_id=t1 attempt=0
|
||||
time=3 level=INFO msg="task completed" task_id=t1 elapsed_seconds=2
|
||||
time=4 level=INFO msg="task claimed" task_id=t2 attempt=0
|
||||
time=5 level=WARN msg="task failed" task_id=t2 error_code=X retryable=true
|
||||
time=6 level=WARN msg="agent cycle failed" error="boom"
|
||||
`
|
||||
if err := os.WriteFile(logPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := parseWorkerStats(logPath)
|
||||
if !stats.Registered || stats.Claimed != 2 || stats.Completed != 1 || stats.Failed != 1 {
|
||||
t.Errorf("stats = %+v, want registered claimed=2 completed=1 failed=1", stats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,12 @@ code{font-family:var(--mono);font-size:.86em}
|
||||
<div><h1 id="st-title">Worker is working</h1><div class="sub" id="st-sub">—</div></div>
|
||||
<button class="btn btn-danger" id="st-stop" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><b id="st-claimed">—</b><span>claimed</span></div>
|
||||
<div class="stat"><b id="st-completed">—</b><span>completed</span></div>
|
||||
<div class="stat bad"><b id="st-failed">—</b><span>failed</span></div>
|
||||
<div class="stat"><b id="st-registered">—</b><span>registered</span></div>
|
||||
</div>
|
||||
<div class="meta-line" id="st-meta"></div>
|
||||
<div class="logbox" id="st-log"></div>
|
||||
<div class="actions"><span class="link" id="st-cfg">—</span><button class="btn btn-ghost" id="st-reconfig">Reconfigure…</button></div>
|
||||
@@ -293,6 +299,11 @@ async function refreshStatus(){
|
||||
$('st-title').textContent=v.running?(v.worker_name||'Worker')+' is working':(v.worker_name||'Worker')+' is stopped';
|
||||
$('st-sub').textContent='pid '+(v.pid||'—')+' · config '+(v.config_present?v.config_path:'not saved yet');
|
||||
$('st-cfg').textContent='Configuration: '+(v.config_present?v.config_path:'—');
|
||||
const stats=v.stats||{};
|
||||
$('st-claimed').textContent=stats.claimed!=null?stats.claimed:'—';
|
||||
$('st-completed').textContent=stats.completed!=null?stats.completed:'—';
|
||||
$('st-failed').textContent=stats.failed!=null?stats.failed:'—';
|
||||
$('st-registered').textContent=stats.registered?'yes':'no';
|
||||
const meta=$('st-meta');
|
||||
meta.innerHTML='';
|
||||
if(v.coordinator){const c=document.createElement('span');c.className='chip';c.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>'+v.coordinator;meta.append(c)}
|
||||
|
||||
Reference in New Issue
Block a user