Add SSH key management, job history persistence, and live streaming

- SSH key management: generate ed25519 keypairs or import public keys
  from UI (/ssh-keys), per-machine key selection in Machines form,
  one-time private key download with hash verification
- Fix engine to use machine-specific SSH key (was hardcoded to server key)
- Job log persistence: write to job_logs table (DB) with batched inserts,
  buffer of 50 lines; GetAllFiltered with status/pair/date range filters
- EventBus refactor: per-job subscriber channels, global channel, non-blocking
- SSE endpoints: /jobs/stream (all), /jobs/:id/log/stream (per-job live)
- JobDetail page: live log streaming, auto-scroll, cancel, duration
- JobHistory: filters (pair, status, date range), pagination, link to detail
- Cleanup scheduler: daily purge of job_logs and finished jobs older than
  SYNCSERVER_RETENTION_DAYS (default 30)
- Migration 0002: indexes on job_logs(job_id), jobs(status,created_at),
  jobs(sync_pair_id)
This commit is contained in:
2026-07-07 20:36:11 -04:00
parent 5374ab81cc
commit bfa006f4ab
22 changed files with 1424 additions and 136 deletions
+75 -15
View File
@@ -2,6 +2,7 @@ package api
import (
"database/sql"
"fmt"
"net/http"
"os"
"strconv"
@@ -28,8 +29,30 @@ func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) {
limit = 50
}
repo := models.NewJobRepository(h.db)
jobs, err := repo.GetAll(limit, offset)
var syncPairID *int64
if spidStr := r.URL.Query().Get("sync_pair_id"); spidStr != "" {
if spid, err := strconv.ParseInt(spidStr, 10, 64); err == nil {
syncPairID = &spid
}
}
status := r.URL.Query().Get("status")
triggerType := r.URL.Query().Get("trigger_type")
var from, to *time.Time
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
from = &t
}
}
if toStr := r.URL.Query().Get("to"); toStr != "" {
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
to = &t
}
}
repo := models.NewJobLogRepository(h.db)
jobs, total, err := repo.GetAllFiltered(limit, offset, syncPairID, status, triggerType, from, to)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch jobs")
return
@@ -37,8 +60,9 @@ func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) {
out := make([]JobResponse, len(jobs))
for i, j := range jobs {
out[i] = jobToResp(j)
out[i] = jobWithStatsToResp(j)
}
w.Header().Set("X-Total-Count", fmt.Sprintf("%d", total))
writeJSON(w, out)
}
@@ -120,30 +144,59 @@ func (h *JobHandler) TriggerRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, jobToResp(*j), http.StatusCreated)
}
func (h *JobHandler) StreamLog(w http.ResponseWriter, r *http.Request) {
func (h *JobHandler) GetLog(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 1000
}
logRepo := models.NewJobLogRepository(h.db)
logs, err := logRepo.GetByJobID(id, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch logs")
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher.Flush()
count, _ := logRepo.CountByJobID(id)
w.Header().Set("X-Total-Count", fmt.Sprintf("%d", count))
writeJSON(w, logs)
}
func (h *JobHandler) DownloadLog(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
jobRepo := models.NewJobRepository(h.db)
j, err := jobRepo.GetByID(id)
if err == nil && j.LogFile != nil {
data, _ := os.ReadFile(*j.LogFile)
w.Write(data)
flusher.Flush()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
if j.LogFile != nil {
data, err := os.ReadFile(*j.LogFile)
if err == nil {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="job-%d.log"`, id))
w.Write(data)
return
}
}
logRepo := models.NewJobLogRepository(h.db)
logs, _ := logRepo.GetByJobID(id, 100000, 0)
for _, l := range logs {
fmt.Fprintf(w, "[%s] %s\n", l.Timestamp.Format(time.RFC3339), l.Content)
}
}
@@ -165,3 +218,10 @@ func jobToResp(j models.Job) JobResponse {
}
return resp
}
func jobWithStatsToResp(j models.JobWithStats) JobResponse {
resp := jobToResp(j.Job)
resp.DurationSeconds = j.DurationSeconds
resp.LogLineCount = &j.LogLineCount
return resp
}