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:
@@ -79,6 +79,7 @@ func run() error {
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
hub := ws.NewHub()
|
||||
jobHub := ws.NewJobHub()
|
||||
|
||||
renderer, err := ui.New()
|
||||
if err != nil {
|
||||
@@ -90,6 +91,7 @@ func run() error {
|
||||
Store: st,
|
||||
AEAD: aead,
|
||||
Hub: hub,
|
||||
JobHub: jobHub,
|
||||
UI: renderer,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Deps struct {
|
||||
Store *store.Store
|
||||
AEAD *crypto.AEAD
|
||||
Hub *ws.Hub
|
||||
JobHub *ws.JobHub
|
||||
UI *ui.Renderer
|
||||
// Version is the binary's build version, surfaced in the chrome.
|
||||
// Empty falls back to "dev".
|
||||
@@ -110,6 +111,7 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
||||
Hub: s.deps.Hub,
|
||||
Store: s.deps.Store,
|
||||
JobHub: s.deps.JobHub,
|
||||
OnHello: s.onAgentHello,
|
||||
}))
|
||||
}
|
||||
@@ -138,6 +140,15 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/hosts/new", s.handleUIAddHostPost)
|
||||
// Host detail (Snapshots tab is the default).
|
||||
r.Get("/hosts/{id}", s.handleUIHostDetail)
|
||||
// Live job log.
|
||||
r.Get("/jobs/{id}", s.handleUIJobDetail)
|
||||
}
|
||||
|
||||
// Browser job-log stream (separate from /ws/agent so the auth
|
||||
// layer is session-cookie not bearer). Mounted regardless of
|
||||
// whether the UI is up — JSON callers may also subscribe.
|
||||
if s.deps.JobHub != nil {
|
||||
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,13 @@ func funcMap() template.FuncMap {
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"derefInt": func(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
type HandlerDeps struct {
|
||||
Hub *Hub
|
||||
Store *store.Store
|
||||
JobHub *JobHub
|
||||
// OnHello is called once per successful hello, after the host row
|
||||
// has been touched and the conn registered. Used by the HTTP
|
||||
// layer to push host_credentials down as a config.update before
|
||||
@@ -172,12 +173,20 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
|
||||
if err := deps.Store.MarkJobStarted(ctx, p.JobID, p.StartedAt); err != nil {
|
||||
slog.Warn("ws: mark job started", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
if deps.JobHub != nil {
|
||||
deps.JobHub.Broadcast(p.JobID, env)
|
||||
}
|
||||
|
||||
case api.MsgJobProgress:
|
||||
// We don't persist every progress tick; the live UI subscribes
|
||||
// to a fan-out channel that lands with P1-21 / the UI work.
|
||||
// TODO: implement the ws fan-out hub for browsers.
|
||||
_ = env
|
||||
// Progress ticks aren't persisted (1Hz × every job × every
|
||||
// path-walk would dwarf the rest of the DB). The live UI
|
||||
// subscribes to JobHub and gets them in real time; once a
|
||||
// job finishes the final summary lands via job.finished.
|
||||
var p api.JobProgressPayload
|
||||
_ = env.UnmarshalPayload(&p)
|
||||
if deps.JobHub != nil {
|
||||
deps.JobHub.Broadcast(p.JobID, env)
|
||||
}
|
||||
|
||||
case api.MsgJobFinished:
|
||||
var p api.JobFinishedPayload
|
||||
@@ -187,6 +196,9 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
|
||||
string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil {
|
||||
slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
if deps.JobHub != nil {
|
||||
deps.JobHub.Broadcast(p.JobID, env)
|
||||
}
|
||||
|
||||
case api.MsgLogStream:
|
||||
var p api.LogStreamLine
|
||||
@@ -195,6 +207,9 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
|
||||
string(p.Stream), p.Payload); err != nil {
|
||||
slog.Warn("ws: append job log", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
if deps.JobHub != nil {
|
||||
deps.JobHub.Broadcast(p.JobID, env)
|
||||
}
|
||||
|
||||
case api.MsgSnapshotsRpt:
|
||||
var p api.SnapshotsReportPayload
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
)
|
||||
|
||||
// JobHub fans agent-emitted job messages (job.progress, log.stream,
|
||||
// job.started, job.finished) out to every browser currently watching
|
||||
// the matching job_id over /api/jobs/{id}/stream.
|
||||
//
|
||||
// Decoupled from the agent Hub: many subscribers per job_id, all
|
||||
// read-only, lifecycle tied to the browser WS rather than the agent's.
|
||||
type JobHub struct {
|
||||
mu sync.RWMutex
|
||||
subs map[string]map[*subscriber]struct{} // job_id → set
|
||||
}
|
||||
|
||||
// NewJobHub returns an empty hub.
|
||||
func NewJobHub() *JobHub {
|
||||
return &JobHub{subs: make(map[string]map[*subscriber]struct{})}
|
||||
}
|
||||
|
||||
// subscriber is one browser WS subscription. Each gets its own
|
||||
// buffered channel + writer goroutine so a slow client can't block
|
||||
// the broadcaster (or, transitively, the agent's read loop).
|
||||
type subscriber struct {
|
||||
jobID string
|
||||
ch chan api.Envelope
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber for jobID. Run pumps messages
|
||||
// from the subscriber's channel onto conn until ctx is cancelled or
|
||||
// conn dies; it returns when one of those happens. Caller is
|
||||
// expected to call this from the goroutine that owns conn.
|
||||
//
|
||||
// If the subscriber's send channel fills, broadcasts drop messages
|
||||
// for that subscriber rather than blocking. The browser will see a
|
||||
// gap; on completion the page can re-fetch persisted log_lines to
|
||||
// reconcile.
|
||||
func (h *JobHub) Subscribe(ctx context.Context, jobID string, conn *Conn) {
|
||||
const buf = 64
|
||||
s := &subscriber{jobID: jobID, ch: make(chan api.Envelope, buf)}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.subs[jobID] == nil {
|
||||
h.subs[jobID] = make(map[*subscriber]struct{})
|
||||
}
|
||||
h.subs[jobID][s] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
if set, ok := h.subs[jobID]; ok {
|
||||
delete(set, s)
|
||||
if len(set) == 0 {
|
||||
delete(h.subs, jobID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Drain pump.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case env, ok := <-s.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := conn.Send(sendCtx, env)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Info("ws browser send failed; closing subscriber", "job_id", jobID, "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends env to every subscriber for jobID. Non-blocking:
|
||||
// if a subscriber's buffer is full, the message is dropped for that
|
||||
// subscriber and a warning is logged. Other subscribers are
|
||||
// unaffected.
|
||||
//
|
||||
// Safe to call from any goroutine; holds an RLock briefly to snapshot
|
||||
// the subscriber set, then releases before sending.
|
||||
func (h *JobHub) Broadcast(jobID string, env api.Envelope) {
|
||||
h.mu.RLock()
|
||||
set := h.subs[jobID]
|
||||
if len(set) == 0 {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
targets := make([]*subscriber, 0, len(set))
|
||||
for s := range set {
|
||||
targets = append(targets, s)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, s := range targets {
|
||||
select {
|
||||
case s.ch <- env:
|
||||
default:
|
||||
// Buffer full — drop. Logged once per drop; a flood means
|
||||
// the browser is genuinely stuck, not just slow.
|
||||
slog.Warn("ws browser sub: send buffer full, dropping message",
|
||||
"job_id", jobID, "type", env.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount returns the number of browsers currently watching
|
||||
// jobID. Used for diagnostics / future "this many people are
|
||||
// watching" counters.
|
||||
func (h *JobHub) SubscriberCount(jobID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subs[jobID])
|
||||
}
|
||||
@@ -91,6 +91,48 @@ func (s *Store) AppendJobLog(ctx context.Context, jobID string, seq int64, ts ti
|
||||
return nil
|
||||
}
|
||||
|
||||
// JobLogLine is one persisted log line, ready to render.
|
||||
type JobLogLine struct {
|
||||
Seq int64
|
||||
TS time.Time
|
||||
Stream string // stdout|stderr|event
|
||||
Payload string
|
||||
}
|
||||
|
||||
// ListJobLogs returns persisted log lines for a job in seq order.
|
||||
// afterSeq lets pagers / reconnect-resuming clients fetch only the
|
||||
// tail; passing 0 returns from the beginning. limit caps the result
|
||||
// (0 means no cap).
|
||||
func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, limit int) ([]JobLogLine, error) {
|
||||
q := `SELECT seq, ts, stream, payload FROM job_logs
|
||||
WHERE job_id = ? AND seq > ? ORDER BY seq ASC`
|
||||
args := []any{jobID, afterSeq}
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list job logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []JobLogLine
|
||||
for rows.Next() {
|
||||
var l JobLogLine
|
||||
var ts string
|
||||
if err := rows.Scan(&l.Seq, &ts, &l.Stream, &l.Payload); err != nil {
|
||||
return nil, fmt.Errorf("store: scan job log: %w", err)
|
||||
}
|
||||
t, perr := time.Parse(time.RFC3339Nano, ts)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("store: parse job log ts: %w", perr)
|
||||
}
|
||||
l.TS = t
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetJob returns a job row.
|
||||
func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
|
||||
@@ -58,7 +58,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
- [x] **P1-23** (M) Base layout, login page, session-aware nav
|
||||
- [x] **P1-24** (M) Dashboard: fleet summary tiles + host table (status dot + row accent + os/arch + last backup + repo size + snapshots + alerts + tags + run-now). Backed by `GET /api/hosts` + `GET /api/fleet/summary` (JSON) and a server-rendered HTML view. Empty state hands the operator the install command. HTMX `Run now` button posts to `/hosts/{id}/run-backup`.
|
||||
- [x] **P1-25** (M) Host detail page (`/hosts/{id}`): persistent header (status dot + mono name + tags + OS/arch/agent/restic/last-seen), vitals strip (last backup / repo size / snapshots / open alerts), sub-tabs (Snapshots active; Jobs/Repo/Settings tabs visible but inert until P2), snapshot table (cap 50, pagination later), right-rail run-now stack (backup live; forget/prune/check/unlock disabled with P2 hints) and a danger-zone delete panel.
|
||||
- [ ] **P1-26** (M) Live job log viewer (WS-driven, auto-scroll, cancel button)
|
||||
- [x] **P1-26** (M) Live job log viewer + WS browser fan-out hub (closes the P1-21 remainder). Browser opens `/api/jobs/{id}/stream`; agent-emitted `job.started`/`job.progress`/`log.stream`/`job.finished` are mirrored to subscribers. Per-subscriber buffered channel + non-blocking broadcast keeps a slow browser from blocking the agent's read loop. Page renders running / succeeded / failed states; auto-scrolls until the operator scrolls up; reloads on `job.finished` to show the final header. "Run now" sets `HX-Redirect` so the operator lands on the live log.
|
||||
- [~] **P1-27** (M) "Add host" flow: form takes hostname + repo URL/username/password, mints token (TTL 1h), re-renders the same page in result-state with the install command (`RM_SERVER` + `RM_TOKEN` filled in), copy button, and an awaiting-agent panel. Encrypted repo creds ride on the token row (P1-32) and get pushed to the agent on first WS connect (P1-33). **Deferred:** one-click "download preconfigured installer" `install-<hostname>.sh` (cf. UrBackup Internet-mode push installer) — copy-paste covers it for v1.
|
||||
- [x] **P1-28** (S) Tailwind build via `tailwindcss` standalone binary (no Node) — Makefile downloads pinned v3.4.17 into `bin/tailwindcss`, builds `web/styles/input.css` → `web/static/css/styles.css`, embedded into the binary via `web.FS`. `make build` runs Tailwind first.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,262 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$page := .Page}}
|
||||
{{$job := $page.Job}}
|
||||
{{$host := $page.Host}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
|
||||
|
||||
<div class="crumbs">
|
||||
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||
<a href="/hosts/{{$host.ID}}">{{$host.Name}}</a><span class="sep">/</span>
|
||||
<span class="text-ink-mid">job {{slice $job.ID 0 8}}…{{slice $job.ID (sub (len $job.ID) 4) (len $job.ID)}}</span>
|
||||
</div>
|
||||
|
||||
{{/* ---------- header ---------- */}}
|
||||
<div class="flex items-start justify-between mt-3.5">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
{{if $page.IsActive}}
|
||||
<span class="dot pulse" style="background: var(--accent);"></span>
|
||||
{{else if eq $job.Status "succeeded"}}
|
||||
<span class="dot dot-online"></span>
|
||||
{{else if eq $job.Status "failed"}}
|
||||
<span class="dot dot-failed"></span>
|
||||
{{else}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{end}}
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.01em]">
|
||||
{{$job.Kind}} <span class="text-ink-fade">·</span>
|
||||
<span class="mono text-ink font-medium">{{$host.Name}}</span>
|
||||
</h1>
|
||||
{{if eq $job.Status "queued"}}
|
||||
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
||||
style="background: color-mix(in oklch, var(--ink-mid), transparent 88%); color: var(--ink-mid); border: 1px solid var(--line-soft);">queued</span>
|
||||
{{else if eq $job.Status "running"}}
|
||||
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
||||
style="background: color-mix(in oklch, var(--accent), transparent 88%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 70%);">running</span>
|
||||
{{else if eq $job.Status "succeeded"}}
|
||||
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
||||
style="background: color-mix(in oklch, var(--ok), transparent 88%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">succeeded</span>
|
||||
{{else if eq $job.Status "failed"}}
|
||||
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
||||
style="background: color-mix(in oklch, var(--bad), transparent 88%); color: var(--bad); border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);">failed</span>
|
||||
{{else}}
|
||||
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
||||
style="background: color-mix(in oklch, var(--warn), transparent 88%); color: var(--warn); border: 1px solid color-mix(in oklch, var(--warn), transparent 70%);">{{$job.Status}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2.5 text-[12.5px] text-ink-mute">
|
||||
<span>job <span class="mono text-ink-mid">{{$job.ID}}</span></span>
|
||||
{{if $job.StartedAt}}
|
||||
<span class="text-ink-fade">·</span>
|
||||
<span>started <span class="mono text-ink-mid">{{relTime $job.StartedAt}}</span></span>
|
||||
{{end}}
|
||||
{{if $job.FinishedAt}}
|
||||
<span class="text-ink-fade">·</span>
|
||||
<span>finished <span class="mono text-ink-mid">{{relTime $job.FinishedAt}}</span></span>
|
||||
{{end}}
|
||||
{{if $job.ExitCode}}
|
||||
<span class="text-ink-fade">·</span>
|
||||
<span>exit code <span class="mono {{if eq $job.Status "failed"}}text-bad{{else}}text-ink-mid{{end}}">{{derefInt $job.ExitCode}}</span></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if $page.IsActive}}
|
||||
<button class="btn btn-danger" id="cancel-btn"
|
||||
hx-post="/api/jobs/{{$job.ID}}/cancel"
|
||||
hx-swap="none">Cancel job</button>
|
||||
{{else}}
|
||||
<a href="/hosts/{{$host.ID}}" class="btn">Back to host</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---------- progress (running only) ---------- */}}
|
||||
{{if $page.IsActive}}
|
||||
<div class="mt-7" id="progress-block">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="mono text-ink font-medium" id="progress-pct">—</span>
|
||||
<span class="text-ink-mute" id="progress-bytes"></span>
|
||||
</div>
|
||||
<div class="text-sm text-ink-mute" id="progress-rate"></div>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---------- failure summary (failed only) ---------- */}}
|
||||
{{if eq $job.Status "failed"}}
|
||||
<div class="panel mt-6 p-4 rounded-[7px]" style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-bad mb-1.5">Failure</div>
|
||||
{{if $job.Error}}
|
||||
<p class="mono text-[13px] text-ink leading-[1.6]">{{deref $job.Error}}</p>
|
||||
{{else}}
|
||||
<p class="text-ink-mute text-[13px]">No error message captured. Inspect the log below for details.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---------- log viewer ---------- */}}
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Stream</h2>
|
||||
<span class="text-[11.5px] text-ink-fade" id="stream-status">
|
||||
{{if $page.IsActive}}
|
||||
following · auto-scroll on
|
||||
{{else}}
|
||||
<span class="text-ink-fade">complete · {{len $page.Logs}} lines</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost" id="follow-btn" style="display: none;">⇢ Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log" id="log-container">
|
||||
<div id="log-stream">
|
||||
{{range $page.Logs}}
|
||||
<div class="log-line">
|
||||
<span class="log-ts">{{.TS.Format "15:04:05.000"}}</span>
|
||||
<span class="log-tag">{{if eq .Stream "stdout"}}OUT{{else if eq .Stream "stderr"}}ERR{{else}}EVENT{{end}}</span>
|
||||
<span class="log-stream-{{.Stream}}">{{.Payload}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and $page.IsActive (eq (len $page.Logs) 0)}}
|
||||
<div class="log-line">
|
||||
<span class="log-ts" style="color: var(--accent);">···</span>
|
||||
<span class="log-tag" style="color: var(--accent);">…</span>
|
||||
<span style="color: var(--accent);">awaiting agent output</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{if $page.IsActive}}
|
||||
<script>
|
||||
(function() {
|
||||
const jobID = {{$job.ID | printf "%q"}};
|
||||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${wsProto}://${location.host}/api/jobs/${jobID}/stream`);
|
||||
const stream = document.getElementById('log-stream');
|
||||
const container = document.getElementById('log-container');
|
||||
const fill = document.getElementById('progress-fill');
|
||||
const pct = document.getElementById('progress-pct');
|
||||
const bytes = document.getElementById('progress-bytes');
|
||||
const rate = document.getElementById('progress-rate');
|
||||
const status = document.getElementById('stream-status');
|
||||
const followBtn = document.getElementById('follow-btn');
|
||||
|
||||
let autoScroll = true;
|
||||
// If the user scrolls up, stop auto-scrolling and surface a "Follow" button.
|
||||
container.addEventListener('scroll', () => {
|
||||
const atBottom = container.scrollHeight - container.clientHeight - container.scrollTop < 8;
|
||||
if (!atBottom && autoScroll) {
|
||||
autoScroll = false;
|
||||
followBtn.style.display = '';
|
||||
status.textContent = 'paused · scroll to follow';
|
||||
} else if (atBottom && !autoScroll) {
|
||||
autoScroll = true;
|
||||
followBtn.style.display = 'none';
|
||||
status.textContent = 'following · auto-scroll on';
|
||||
}
|
||||
});
|
||||
followBtn.addEventListener('click', () => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||
}[c]));
|
||||
}
|
||||
function fmtTs(iso) {
|
||||
// Show HH:MM:SS.mmm in local time. Falls back to iso if parsing fails.
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
return `${pad(d.getHours(),2)}:${pad(d.getMinutes(),2)}:${pad(d.getSeconds(),2)}.${pad(d.getMilliseconds(),3)}`;
|
||||
}
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B';
|
||||
const u = ['B','kB','MB','GB','TB'];
|
||||
let i = 0;
|
||||
while (n >= 1000 && i < u.length-1) { n /= 1000; i++; }
|
||||
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function appendLine(p) {
|
||||
// Drop the "awaiting" placeholder once real lines arrive.
|
||||
if (stream.children.length === 1 && stream.firstElementChild.textContent.includes('awaiting agent')) {
|
||||
stream.firstElementChild.remove();
|
||||
}
|
||||
const tag = p.stream === 'stdout' ? 'OUT' : p.stream === 'stderr' ? 'ERR' : 'EVENT';
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log-line';
|
||||
line.innerHTML =
|
||||
`<span class="log-ts">${fmtTs(p.ts)}</span>` +
|
||||
`<span class="log-tag">${tag}</span>` +
|
||||
`<span class="log-stream-${p.stream}">${escapeHtml(p.payload)}</span>`;
|
||||
stream.appendChild(line);
|
||||
if (autoScroll) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let env;
|
||||
try { env = JSON.parse(ev.data); } catch { return; }
|
||||
let p;
|
||||
try { p = env.payload ? JSON.parse(JSON.stringify(env.payload)) : {}; } catch { p = {}; }
|
||||
// The server sends payload as a JSON object literal, but Go wire
|
||||
// wraps it as raw JSON in the envelope. After JSON.parse(ev.data)
|
||||
// payload is already an object — no second parse needed.
|
||||
p = env.payload || {};
|
||||
switch (env.type) {
|
||||
case 'log.stream':
|
||||
appendLine(p);
|
||||
break;
|
||||
case 'job.progress': {
|
||||
const percent = Math.round((p.percent_done || 0) * 100);
|
||||
fill.style.width = percent + '%';
|
||||
pct.textContent = percent + '%';
|
||||
const totals = [];
|
||||
if (p.bytes_done && p.total_bytes) totals.push(`${fmtBytes(p.bytes_done)} of ${fmtBytes(p.total_bytes)}`);
|
||||
if (p.files_done && p.total_files) totals.push(`${p.files_done.toLocaleString()} of ${p.total_files.toLocaleString()} files`);
|
||||
bytes.textContent = totals.length ? '· ' + totals.join(' · ') : '';
|
||||
const parts = [];
|
||||
if (p.throughput_bps) parts.push(`${fmtBytes(p.throughput_bps)}/s`);
|
||||
if (p.eta_seconds) parts.push(`ETA ${Math.floor(p.eta_seconds/60)}m ${p.eta_seconds%60}s`);
|
||||
rate.textContent = parts.join(' · ');
|
||||
break;
|
||||
}
|
||||
case 'job.started':
|
||||
// Already shown in the header on a fresh page load; if we
|
||||
// arrive before the job moves to "running" the next progress
|
||||
// tick will paint it.
|
||||
break;
|
||||
case 'job.finished':
|
||||
// Reload to render the final-state header (stats / exit code
|
||||
// / end-of-stream markers come from the server).
|
||||
setTimeout(() => location.reload(), 600);
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { status.textContent = 'connection error · reload to retry'; };
|
||||
ws.onclose = () => {
|
||||
if (status.textContent.startsWith('following')) {
|
||||
status.textContent = 'stream closed';
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user