P1-26: live job log viewer + WS browser fan-out hub
Closes the P1-21 remainder.
internal/server/ws/jobhub.go — new JobHub. Per-job_id set of
subscribers; each gets a 64-deep buffered channel with a writer
goroutine. Broadcast is non-blocking: if a subscriber is slow,
its channel fills and messages are dropped for that subscriber
only — the agent's read loop is never blocked by a stuck browser.
The agent dispatchAgentMessage path mirrors job.started /
job.progress / log.stream / job.finished envelopes onto the hub
in addition to its existing persistence work. The wire shape is
the same end-to-end, so client-side JS switches on env.type the
same way Go code does.
GET /api/jobs/{id}/stream is the browser endpoint. Auth via
session cookie (HTTP layer); upgrade; subscribe; pump until
context closes.
GET /jobs/{id} renders the live log page. Three states (queued/
running/succeeded/failed) drive the header pill, the progress
bar block, the failure summary panel, and the action button
(Cancel job while running, Back to host afterwards). Already-
persisted log lines are server-rendered on initial load; new
lines arrive over the WS and append to #log-stream. Auto-scrolls
unless the user scrolls up (a "⇢ Follow" pill re-attaches).
On job.finished the page reloads after 600ms to pick up the
final-state header rendered server-side.
POST /hosts/{id}/run-backup now sets HX-Redirect → /jobs/{job_id}
on success so HTMX lands the operator straight on the live log.
For non-HTMX callers (curl / plain form post) it 303s to the
same target.
store.ListJobLogs returns persisted log lines for initial render
on page load.
Browser-verified end-to-end: enrol → run a real backup against a
sibling restic/rest-server → live progress + 11 log lines stream
in → succeeded pill + final stats land after page reload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/web"
|
||||
)
|
||||
@@ -138,10 +140,10 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
}
|
||||
|
||||
// handleUIRunBackup is the form-submit twin of POST /api/hosts/{id}/jobs
|
||||
// that the dashboard's "Run now" buttons call via hx-post. Returns
|
||||
// 204 on success — HTMX swap=none means "did the thing, no DOM
|
||||
// change needed." Failures return text in the body so HTMX's
|
||||
// response-header inspection surfaces it.
|
||||
// that the dashboard / host-detail "Run now" buttons call via
|
||||
// hx-post. On success it sets HX-Redirect → /jobs/{job_id} so the
|
||||
// operator lands on the live log viewer for the job they just
|
||||
// kicked off.
|
||||
func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
@@ -157,12 +159,23 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil)
|
||||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil)
|
||||
if code != "" {
|
||||
stdhttp.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(stdhttp.StatusNoContent)
|
||||
// HTMX (with hx-post + hx-swap=none) doesn't honour HX-Redirect
|
||||
// when the response itself is a 3xx — fetch follows the redirect
|
||||
// first and the header is lost. Branch on the HX-Request marker
|
||||
// so HTMX gets a 200 + HX-Redirect (client-side window.location
|
||||
// hop), while plain form-post / curl callers get the 303.
|
||||
target := "/jobs/" + res.JobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// addHostPage carries the form state into the Add host template.
|
||||
@@ -332,6 +345,105 @@ func (s *Server) publicURL(r *stdhttp.Request) string {
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// jobDetailPage carries everything the live-log template renders.
|
||||
type jobDetailPage struct {
|
||||
Job store.Job
|
||||
Host store.Host
|
||||
Logs []store.JobLogLine
|
||||
NextSeq int64
|
||||
IsActive bool // true while status is queued|running
|
||||
}
|
||||
|
||||
// handleUIJobDetail renders the live job log view (snapshot of any
|
||||
// already-persisted log lines + an empty stream container the JS
|
||||
// fills via the WS).
|
||||
func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
jobID := chi.URLParam(r, "id")
|
||||
if jobID == "" {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
job, err := s.deps.Store.GetJob(r.Context(), jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
host, err := s.deps.Store.GetHost(r.Context(), job.HostID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var nextSeq int64
|
||||
if n := len(logs); n > 0 {
|
||||
nextSeq = logs[n-1].Seq
|
||||
}
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = job.Kind + " · " + host.Name + " · restic-manager"
|
||||
view.Page = jobDetailPage{
|
||||
Job: *job,
|
||||
Host: *host,
|
||||
Logs: logs,
|
||||
NextSeq: nextSeq,
|
||||
IsActive: job.Status == "queued" || job.Status == "running",
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "job_detail", view); err != nil {
|
||||
slog.Error("ui: render job_detail", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleJobStream is the browser-side WS endpoint. Auth is via the
|
||||
// session cookie (the HTTP layer does the lookup before upgrading).
|
||||
// On connect we subscribe to JobHub for the given job_id; the
|
||||
// subscriber goroutine pumps fan-out messages to the client until
|
||||
// the job finishes or the browser navigates away.
|
||||
//
|
||||
// Messages on the wire are the same api.Envelope shape as on the
|
||||
// agent side, so the client-side JS can switch on env.type the
|
||||
// same way our Go code does.
|
||||
func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if u, _ := s.sessionUser(r); u == nil {
|
||||
stdhttp.Error(w, "unauthorized", stdhttp.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
jobID := chi.URLParam(r, "id")
|
||||
if jobID == "" {
|
||||
stdhttp.Error(w, "missing job id", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.Store.GetJob(r.Context(), jobID); err != nil {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true, // Origin checks pointless for a same-origin browser hop.
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("ws browser accept failed", "job_id", jobID, "err", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
|
||||
|
||||
// Wrap so we get the same Send semantics as the agent path.
|
||||
c := ws.NewConn("browser-"+jobID, conn)
|
||||
s.deps.JobHub.Subscribe(r.Context(), jobID, c)
|
||||
}
|
||||
|
||||
// userByID fetches the full store.User the UI session represents.
|
||||
// Returns the user, ok-flag, error. Used by handlers that need the
|
||||
// store-side row (e.g. for audit_log.user_id) rather than just the
|
||||
|
||||
Reference in New Issue
Block a user