From a535822ff33f7af043a4bc4e8daf2e9dade536c1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 11:37:55 +0100 Subject: [PATCH 01/16] P2R-02 slice 1: host-detail sub-tab skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract header/vitals/sub-tabs into a host_chrome partial that every host-detail tab page renders. Sources / Schedules / Repo go from inert divs to real links backed by stub pages that share the chrome and a 'coming next' body — slices 2/3/4 fill them in. Also re-establishes the version indicator (host_schedule_version vs agent's applied_schedule_version) in the header. Drops the legacy fat-schedule list/edit templates that referenced fields removed by the P2 redesign (Manual / Paths / RetentionPolicy on Schedule); the new templates land in slice 3. --- internal/server/http/server.go | 4 + internal/server/http/ui_handlers.go | 39 ++++- internal/server/http/ui_repo.go | 34 ++++ internal/server/http/ui_schedules.go | 73 +++++++-- internal/server/http/ui_sources.go | 34 ++++ internal/server/ui/ui.go | 1 + web/static/css/styles.css | 2 +- web/templates/pages/host_detail.html | 99 +----------- web/templates/pages/host_repo.html | 14 ++ web/templates/pages/host_schedules.html | 13 ++ web/templates/pages/host_sources.html | 13 ++ web/templates/pages/schedule_edit.html | 198 ------------------------ web/templates/pages/schedules_list.html | 105 ------------- web/templates/partials/host_chrome.html | 118 ++++++++++++++ 14 files changed, 336 insertions(+), 411 deletions(-) create mode 100644 internal/server/http/ui_repo.go create mode 100644 internal/server/http/ui_sources.go create mode 100644 web/templates/pages/host_repo.html create mode 100644 web/templates/pages/host_schedules.html create mode 100644 web/templates/pages/host_sources.html delete mode 100644 web/templates/pages/schedule_edit.html delete mode 100644 web/templates/pages/schedules_list.html create mode 100644 web/templates/partials/host_chrome.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 49fb3b3..4272462 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -185,6 +185,10 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) // Host detail (Snapshots tab is the default). r.Get("/hosts/{id}", s.handleUIHostDetail) + // Sources tab (slice 2 fills in CRUD). + r.Get("/hosts/{id}/sources", s.handleUIHostSources) + // Repo tab (slice 4 fills in body). + r.Get("/hosts/{id}/repo", s.handleUIHostRepo) // Schedules tab + create/edit/delete forms. r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 31e6a31..1c2b728 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -397,9 +397,44 @@ type awaitingFragment struct { LastSeenAt *time.Time } +// hostChromeData is the field set the host_chrome partial reads from +// every host-detail-tab page's Page struct. Embed it as the first +// (anonymous) field of the page struct so .Page.Host / .Page.SubTab +// resolve via field promotion in the template. +type hostChromeData struct { + Host store.Host + SubTab string // snapshots | sources | schedules | repo + Crumb string // breadcrumb tail ("snapshots" / "sources" / etc) + SourceGroupCount int + ScheduleCount int + ScheduleVersion int64 // host_schedule_version (latest desired) +} + +// loadHostChrome fetches the per-tab counts that every host-detail tab +// renders in the chrome (sub-tab badges + version indicator). On any +// non-fatal store error it logs and degrades to zeros — better to +// render the page with stale counts than 500 the whole tab. +func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData { + d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb} + if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil { + d.SourceGroupCount = len(groups) + } else { + slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err) + } + if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil { + d.ScheduleCount = len(scheds) + } else { + slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err) + } + if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil { + d.ScheduleVersion = v + } + return d +} + // hostDetailPage carries everything the host detail template needs. type hostDetailPage struct { - Host store.Host + hostChromeData Snapshots []store.Snapshot // SnapshotsShown is the number rendered (we cap at ~50 for the // first slice; pagination lands when it matters). @@ -443,7 +478,7 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request view := s.baseView(u, "dashboard") view.Title = host.Name + " · restic-manager" view.Page = hostDetailPage{ - Host: *host, + hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), Snapshots: shown, SnapshotsShown: len(shown), } diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go new file mode 100644 index 0000000..df9f48a --- /dev/null +++ b/internal/server/http/ui_repo.go @@ -0,0 +1,34 @@ +package http + +import ( + "log/slog" + stdhttp "net/http" +) + +// ui_repo.go — HTML form-driven repo-tab handlers (connection, +// bandwidth caps, maintenance cadences, danger-zone re-init). Slice +// 1 of P2R-02 lights the tab; slice 4 fills in the body. + +type hostRepoPage struct { + hostChromeData +} + +func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + view := s.baseView(u, "dashboard") + view.Title = host.Name + " repo · restic-manager" + view.Page = hostRepoPage{ + hostChromeData: s.loadHostChrome(r, *host, "repo", "repo"), + } + if err := s.deps.UI.Render(w, "host_repo", view); err != nil { + slog.Error("ui: render host_repo", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 1e060d3..d957bbb 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -1,38 +1,83 @@ package http import ( + "errors" + "log/slog" stdhttp "net/http" + + "github.com/go-chi/chi/v5" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -// ui_schedules.go — HTML form-driven schedule CRUD. -// -// Stubbed during the P2 redesign template rewrite. Phase 4 of the -// redesign rebuilds the schedule editor against the new slim shape -// (cron + source-group multi-select + enabled), the source-group -// list/edit pages, and the repo-maintenance tab. Until then these -// routes return 501; the dashboard's host-row "View →" link is the -// only operator entry point that still works. +// ui_schedules.go — HTML form-driven schedule CRUD against the slim +// shape (cron + source-group multi-select + enabled). The list view +// is live as of slice 1 of P2R-02; the new/edit/delete/run handlers +// land in slice 3. + +// hostSchedulesPage is the data the schedules-tab template renders. +type hostSchedulesPage struct { + hostChromeData +} func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + view := s.baseView(u, "dashboard") + view.Title = host.Name + " schedules · restic-manager" + view.Page = hostSchedulesPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", "schedules"), + } + if err := s.deps.UI.Render(w, "host_schedules", view); err != nil { + slog.Error("ui: render host_schedules", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) } func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) } func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) } func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) } func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented) + stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) +} + +// loadHostForUI is a small helper shared across the host-detail tab +// handlers — fetches the host by URL param, writing the appropriate +// 404/500 + returning ok=false on failure. +func (s *Server) loadHostForUI(w stdhttp.ResponseWriter, r *stdhttp.Request) (*store.Host, bool) { + hostID := chi.URLParam(r, "id") + if hostID == "" { + stdhttp.NotFound(w, r) + return nil, false + } + host, err := s.deps.Store.GetHost(r.Context(), hostID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return nil, false + } + slog.Error("ui host tab: get host", "host_id", hostID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return nil, false + } + return host, true } diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go new file mode 100644 index 0000000..67ba846 --- /dev/null +++ b/internal/server/http/ui_sources.go @@ -0,0 +1,34 @@ +package http + +import ( + "log/slog" + stdhttp "net/http" +) + +// ui_sources.go — HTML form-driven source-group CRUD. Slice 1 of +// P2R-02 lights the tab; slice 2 fills in list, new, edit, delete, +// and per-group Run-now. + +type hostSourcesPage struct { + hostChromeData +} + +func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + view := s.baseView(u, "dashboard") + view.Title = host.Name + " sources · restic-manager" + view.Page = hostSourcesPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", "sources"), + } + if err := s.deps.UI.Render(w, "host_sources", view); err != nil { + slog.Error("ui: render host_sources", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 04ab397..905fe7f 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -91,6 +91,7 @@ func New() (*Renderer, error) { "templates/partials/host_row.html", "templates/partials/toast.html", "templates/partials/awaiting_agent.html", + "templates/partials/host_chrome.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/web/static/css/styles.css b/web/static/css/styles.css index e0d47dc..0956f64 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html index e85b2b8..1ccf63b 100644 --- a/web/templates/pages/host_detail.html +++ b/web/templates/pages/host_detail.html @@ -1,93 +1,13 @@ {{define "title"}}{{.Title}}{{end}} {{define "content"}} +{{template "host_chrome" .}} {{$page := .Page}} {{$host := $page.Host}} -
- -
Dashboard/{{$host.Name}}
- - {{/* ---------- header ---------- */}} -
-
-
- {{if eq $host.Status "online"}} - - {{else if eq $host.Status "degraded"}} - - {{else if eq $host.Status "offline"}} - - {{else}} - - {{end}} -

{{$host.Name}}

-
{{range $host.Tags}}{{.}}{{end}}
-
-
- {{$host.OS}}/{{$host.Arch}} - · - agent {{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}} - · - restic {{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}} - · - {{if eq $host.Status "offline"}} - last seen {{relTime $host.LastSeenAt}} - {{else}} - online · last heartbeat {{relTime $host.LastSeenAt}} - {{end}} -
-
-
- - - -
-
- - {{/* ---------- vitals strip ---------- */}} -
-
-
Last backup
-
- {{if eq (deref $host.LastBackupStatus) "succeeded"}} - succeeded - {{else if eq (deref $host.LastBackupStatus) "failed"}} - failed - {{else if eq (deref $host.LastBackupStatus) "cancelled"}} - cancelled - {{else}} - never run - {{end}} - {{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}} -
-
-
-
Repo size
-
{{bytes $host.RepoSizeBytes}}
-
-
-
Snapshots
-
{{comma $host.SnapshotCount}}
-
-
-
Open alerts
-
- {{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}} -
-
-
- - {{/* ---------- secondary tabs ---------- */}} -
- Snapshots {{comma $host.SnapshotCount}} -
Schedules
-
Jobs
-
Repo
-
Settings
-
+
{{/* ---------- snapshots tab ---------- */}} -
+
@@ -106,7 +26,7 @@ Once a backup completes, the agent will refresh this list automatically.

- + Open Sources →
{{else}} @@ -150,13 +70,10 @@
Run-now
-
- - - - - -
+

+ Run-now lives on individual source groups now — + open Sources → +

diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html new file mode 100644 index 0000000..bd3d87d --- /dev/null +++ b/web/templates/pages/host_repo.html @@ -0,0 +1,14 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +
+
+

Repo tab — coming next.

+

+ Connection settings, bandwidth caps, maintenance cadences, and the + danger-zone re-init land in P2R-02 slice 4. +

+
+
+{{end}} diff --git a/web/templates/pages/host_schedules.html b/web/templates/pages/host_schedules.html new file mode 100644 index 0000000..039ac25 --- /dev/null +++ b/web/templates/pages/host_schedules.html @@ -0,0 +1,13 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +
+
+

Schedules tab — coming next.

+

+ The slim-schedule list and form land in P2R-02 slice 3. +

+
+
+{{end}} diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html new file mode 100644 index 0000000..9323c2d --- /dev/null +++ b/web/templates/pages/host_sources.html @@ -0,0 +1,13 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +
+
+

Sources tab — coming next.

+

+ The source-group editor lands in P2R-02 slice 2. +

+
+
+{{end}} diff --git a/web/templates/pages/schedule_edit.html b/web/templates/pages/schedule_edit.html deleted file mode 100644 index fd1c11e..0000000 --- a/web/templates/pages/schedule_edit.html +++ /dev/null @@ -1,198 +0,0 @@ -{{define "title"}}{{.Title}}{{end}} - -{{define "content"}} -{{$page := .Page}} -{{$host := $page.Host}} -
- -
- Dashboard/ - {{$host.Name}}/ - schedules/ - {{if $page.IsNew}}new{{else}}edit{{end}} -
- -

- {{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}} - · - {{$host.Name}} -

-

- Backups run on the cron expression below. The agent applies whatever the server most - recently pushed; an offline agent catches up on the next reconnect. -

- - {{if $page.Error}} -
- {{$page.Error}} -
- {{end}} - -
- -
- -

Kind

-
- {{if $page.IsNew}} - - -
- backup reads files and writes a snapshot. - forget trims the index by your Keep-* rules without deleting data — - an admin-only prune job (P2-06) reclaims the disk space later. - Other kinds (prune, check, unlock) land in P2-06..08. -
- {{else}} - -
- Kind: {{$page.Kind}} - — immutable on edit; delete and recreate to switch kind. -
- {{end}} -
- -

When

- -
- -
- -
- - -
- Standard 5-field cron with descriptors. Examples: - 0 3 * * * (daily 03:00), - @hourly, - */30 * * * * (every 30 min). - Server validates with the same parser the agent uses to fire. -
-
- {{range $cron := list "0 3 * * *" "0 */6 * * *" "@hourly" "0 3 * * 0" "0 3 1 * *"}} - - {{end}} -
-
- -
-

Paths

-
- - -
What restic backup walks. The agent runs as root with CAP_DAC_READ_SEARCH, so any readable path is fair game.
-
-
- - -
Passed straight through as --exclude args.
-
-
- -

Tags · optional

-
- - -
Attached to every snapshot this schedule produces. Useful for retention rules (P2-05).
-
- -

Retention · optional, all blank = keep everything

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Applied by restic forget when the prune job kind lands in P2-05. Mirrors restic's --keep-* flags one-for-one. -
- -

Bandwidth · optional

-
-
- - -
-
- - -
-
- -
- -
- -
- - Cancel -
-
- - - -
- -
-{{end}} diff --git a/web/templates/pages/schedules_list.html b/web/templates/pages/schedules_list.html deleted file mode 100644 index 3451865..0000000 --- a/web/templates/pages/schedules_list.html +++ /dev/null @@ -1,105 +0,0 @@ -{{define "title"}}{{.Title}}{{end}} - -{{define "content"}} -{{$page := .Page}} -{{$host := $page.Host}} -
- -
- Dashboard/ - {{$host.Name}}/ - schedules -
- - {{/* ---------- header ---------- */}} -
-
-
- {{if eq $host.Status "online"}} - - {{else}} - - {{end}} -

- schedules · - {{$host.Name}} -

- version {{$page.Version}}{{if and (gt $page.Version 0) (ne $page.Version $page.AppliedVersion)}} · agent at v{{$page.AppliedVersion}}{{else if gt $page.Version 0}} · agent in sync{{end}} -
-
- -
- - {{/* ---------- secondary tabs ---------- */}} - - - {{/* ---------- schedule rows ---------- */}} -
- {{if eq (len $page.Schedules) 0}} -
-

No schedules yet.

-

- Add one and the agent will start running backups on whatever cron expression you give it. - Until then, run-now is the only way to trigger a backup. -

- -
- {{else}} -
-
Status
-
When
-
Paths
-
Retention
-
Tags
-
-
- {{range $page.Schedules}} -
-
- {{if .Enabled}} - enabled - {{else}} - disabled - {{end}} - {{if .Manual}} - manual - {{end}} -
-
{{if .Manual}}— run-now only —{{else}}{{.CronExpr}}{{end}}
-
{{joinDot .Paths}}
-
{{.RetentionPolicy.Summary}}
-
- {{- range .Tags -}}{{.}}{{- end -}} -
-
- {{if and .Enabled (eq $host.Status "online")}} - - {{end}} - Edit -
- -
-
-
- {{end}} - {{end}} -
- -
-{{end}} diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html new file mode 100644 index 0000000..01606de --- /dev/null +++ b/web/templates/partials/host_chrome.html @@ -0,0 +1,118 @@ +{{/* + host_chrome — header (status dot + name + tags + meta), vitals + strip, and the six sub-tab nav for any /hosts/{id}/... page. + + Expects .Page to expose: + .Host — store.Host + .SubTab — "snapshots" | "sources" | "schedules" | "repo" | "jobs" | "settings" + .SourceGroupCount — int + .ScheduleCount — int + .ScheduleVersion — int64 (host_schedule_version) + .Crumb — string ("snapshots" / "sources" / etc — appended after host name) +*/}} +{{define "host_chrome"}} +{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+ Dashboard/ + {{if eq $page.SubTab "snapshots"}} + {{$host.Name}} + {{else}} + {{$host.Name}}/ + {{$page.Crumb}} + {{end}} +
+ + {{/* ---------- header ---------- */}} +
+
+
+ {{if eq $host.Status "online"}} + + {{else if eq $host.Status "degraded"}} + + {{else if eq $host.Status "offline"}} + + {{else}} + + {{end}} +

{{$host.Name}}

+
{{range $host.Tags}}{{.}}{{end}}
+ {{if gt $page.ScheduleVersion 0}} + + version {{$page.ScheduleVersion}} + {{if eq $page.ScheduleVersion $host.AppliedScheduleVersion}} + · agent in sync + {{else}} + · agent at v{{$host.AppliedScheduleVersion}} + {{end}} + + {{end}} +
+
+ {{$host.OS}}/{{$host.Arch}} + · + agent {{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}} + · + restic {{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}} + · + {{if eq $host.Status "offline"}} + last seen {{relTime $host.LastSeenAt}} + {{else}} + online · last heartbeat {{relTime $host.LastSeenAt}} + {{end}} +
+
+
+ + + +
+
+ + {{/* ---------- vitals strip ---------- */}} +
+
+
Last backup
+
+ {{if eq (deref $host.LastBackupStatus) "succeeded"}} + succeeded + {{else if eq (deref $host.LastBackupStatus) "failed"}} + failed + {{else if eq (deref $host.LastBackupStatus) "cancelled"}} + cancelled + {{else}} + never run + {{end}} + {{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}} +
+
+
+
Repo size
+
{{bytes $host.RepoSizeBytes}}
+
+
+
Snapshots
+
{{comma $host.SnapshotCount}}
+
+
+
Open alerts
+
+ {{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}} +
+
+
+ + {{/* ---------- secondary tabs ---------- */}} + +
+{{end}} -- 2.52.0 From 0ed9c3d1ecb5f641443c03b14dc843d709636550 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 11:44:43 +0100 Subject: [PATCH 02/16] =?UTF-8?q?P2R-02=20slice=202:=20Sources=20tab=20?= =?UTF-8?q?=E2=80=94=20list,=20new/edit=20form,=20delete,=20Run-now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources tab now lists every source group on the host with per-row counts (used-by-N-schedules, snapshot count by tag), the v4 conflict tag (keep-* dimension that has no compatible cadence), and Run-now / Edit / Delete actions. Run-now reuses the existing HTMX-aware /hosts/{id}/source-groups/{gid}/run handler. New /hosts/{id}/sources/new and /sources/{gid}/edit form: name + includes/excludes textareas + the 3×2 keep-* retention grid + retry-on-offline knobs. Server-side validation re-renders with the operator's input intact; the inline conflict banner shows above the retention grid when ConflictDimension is set. Delete blocks (UI + server) when the group is referenced by any schedule. Every successful mutation calls pushScheduleSetAsync so an online agent re-arms within seconds. Adds .src-row and .keep-cell to input.css for the row + retention grid layout. --- internal/server/http/server.go | 7 +- internal/server/http/ui_sources.go | 403 ++++++++++++++++++++- web/static/css/styles.css | 2 +- web/styles/input.css | 26 ++ web/templates/pages/host_sources.html | 83 ++++- web/templates/pages/source_group_edit.html | 132 +++++++ 6 files changed, 641 insertions(+), 12 deletions(-) create mode 100644 web/templates/pages/source_group_edit.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 4272462..ecced85 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -185,8 +185,13 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) // Host detail (Snapshots tab is the default). r.Get("/hosts/{id}", s.handleUIHostDetail) - // Sources tab (slice 2 fills in CRUD). + // Sources tab + source-group CRUD forms. r.Get("/hosts/{id}/sources", s.handleUIHostSources) + r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) + r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave) + r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) + r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) + r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) // Repo tab (slice 4 fills in body). r.Get("/hosts/{id}/repo", s.handleUIHostRepo) // Schedules tab + create/edit/delete forms. diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go index 67ba846..8d48c90 100644 --- a/internal/server/http/ui_sources.go +++ b/internal/server/http/ui_sources.go @@ -1,18 +1,78 @@ package http import ( + "errors" "log/slog" stdhttp "net/http" + "regexp" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -// ui_sources.go — HTML form-driven source-group CRUD. Slice 1 of -// P2R-02 lights the tab; slice 2 fills in list, new, edit, delete, -// and per-group Run-now. +// ui_sources.go — HTML form-driven source-group CRUD. Mounts at: +// GET /hosts/{id}/sources — list +// GET /hosts/{id}/sources/new — empty form +// POST /hosts/{id}/sources/new — create +// GET /hosts/{id}/sources/{gid}/edit — populated form +// POST /hosts/{id}/sources/{gid}/edit — update +// POST /hosts/{id}/sources/{gid}/delete — delete +// +// Per-group Run-now is handled by run_group.go's HTMX-aware +// /hosts/{id}/source-groups/{gid}/run handler. +// hostSourcesPage backs the list view. Each row carries the group plus +// the cheap aggregates the row UI shows (used-by-N-schedules, +// snapshot count by tag). type hostSourcesPage struct { hostChromeData + Groups []sourceGroupRow } +type sourceGroupRow struct { + Group store.SourceGroup + UsedBy int + SnapshotCount int +} + +// sourceFormData carries form state across re-render-on-error. Keep +// keep-* fields as strings so an empty input round-trips as "" (not +// "0"), preserving the operator's intent. +type sourceFormData struct { + Name string + Includes string // newline-joined for the textarea + Excludes string // newline-joined for the textarea + KeepLast string + KeepHourly string + KeepDaily string + KeepWeekly string + KeepMonthly string + KeepYearly string + RetryMax int + RetryBackoffSeconds int + ConflictDimension string +} + +// sourceGroupEditPage backs both the new and edit form views. +type sourceGroupEditPage struct { + hostChromeData + IsNew bool + GroupID string // empty when IsNew + Form sourceFormData + SaveAction string + Error string +} + +// nameRE matches the same shape the wireframe + UI hint advertise: +// lowercase alnum, optional `_-`, no leading punctuation. Mirrors what +// works as a restic --tag. +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { @@ -22,13 +82,344 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques if !ok { return } + + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui sources: list groups", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + // Snapshot counts per tag — single fetch, then bucket by tag. + snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID) + if err != nil { + slog.Warn("ui sources: list snapshots", "host_id", host.ID, "err", err) + } + snapByTag := make(map[string]int, len(groups)) + for _, sn := range snaps { + for _, tag := range sn.Tags { + snapByTag[tag]++ + } + } + + rows := make([]sourceGroupRow, 0, len(groups)) + for _, g := range groups { + usedBy, lerr := s.deps.Store.SchedulesUsingGroup(r.Context(), g.ID) + if lerr != nil { + slog.Warn("ui sources: usage lookup", "group_id", g.ID, "err", lerr) + } + rows = append(rows, sourceGroupRow{ + Group: g, + UsedBy: len(usedBy), + SnapshotCount: snapByTag[g.Name], + }) + } + + chrome := s.loadHostChrome(r, *host, "sources", "sources") + // loadHostChrome already counted groups; reuse count we just got. + chrome.SourceGroupCount = len(groups) + view := s.baseView(u, "dashboard") view.Title = host.Name + " sources · restic-manager" - view.Page = hostSourcesPage{ - hostChromeData: s.loadHostChrome(r, *host, "sources", "sources"), - } + view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows} if err := s.deps.UI.Render(w, "host_sources", view); err != nil { slog.Error("ui: render host_sources", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } + +func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + view := s.baseView(u, "dashboard") + view.Title = "New source group · " + host.Name + " · restic-manager" + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"), + IsNew: true, + Form: sourceFormData{RetryMax: 3, RetryBackoffSeconds: 60}, + SaveAction: "/hosts/" + host.ID + "/sources/new", + } + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (new)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + gid := chi.URLParam(r, "gid") + g, err := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui sources: get group", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(u, "dashboard") + view.Title = g.Name + " · " + host.Name + " · restic-manager" + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name), + IsNew: false, + GroupID: gid, + Form: formFromGroup(*g), + SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit", + } + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (edit)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} + +// handleUISourceGroupSave handles both the create (gid empty) and the +// update (gid set) POST. Validates server-side; on error re-renders +// the form with the operator's typed input intact + a banner. On +// success, redirects back to the list. +func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + gid := chi.URLParam(r, "gid") + isNew := gid == "" + + form := parseSourceForm(r.PostForm) + + // --- validation --- + var errMsg string + switch { + case form.Name == "": + errMsg = "Name is required." + case !nameRE.MatchString(form.Name): + errMsg = "Name must be lowercase letters, digits, dashes, or underscores (and start with a letter or digit)." + } + keepLast, err := parseKeep(form.KeepLast) + if errMsg == "" && err != nil { + errMsg = "Keep last must be a non-negative whole number." + } + keepHourly, err := parseKeep(form.KeepHourly) + if errMsg == "" && err != nil { + errMsg = "Hourly must be a non-negative whole number." + } + keepDaily, err := parseKeep(form.KeepDaily) + if errMsg == "" && err != nil { + errMsg = "Daily must be a non-negative whole number." + } + keepWeekly, err := parseKeep(form.KeepWeekly) + if errMsg == "" && err != nil { + errMsg = "Weekly must be a non-negative whole number." + } + keepMonthly, err := parseKeep(form.KeepMonthly) + if errMsg == "" && err != nil { + errMsg = "Monthly must be a non-negative whole number." + } + keepYearly, err := parseKeep(form.KeepYearly) + if errMsg == "" && err != nil { + errMsg = "Yearly must be a non-negative whole number." + } + + // Name uniqueness (per host). On rename, exclude self. + if errMsg == "" { + if existing, gerr := s.deps.Store.GetSourceGroupByName(r.Context(), host.ID, form.Name); gerr == nil && existing != nil && existing.ID != gid { + errMsg = "A source group named \"" + form.Name + "\" already exists on this host." + } + } + + if errMsg != "" { + s.renderSourceFormError(w, r, u, host, gid, isNew, form, errMsg) + return + } + + g := store.SourceGroup{ + ID: gid, + HostID: host.ID, + Name: form.Name, + Includes: splitLines(form.Includes), + Excludes: splitLines(form.Excludes), + RetentionPolicy: store.RetentionPolicy{ + KeepLast: keepLast, KeepHourly: keepHourly, KeepDaily: keepDaily, + KeepWeekly: keepWeekly, KeepMonthly: keepMonthly, KeepYearly: keepYearly, + }, + RetryMax: form.RetryMax, + RetryBackoffSeconds: form.RetryBackoffSeconds, + } + + if isNew { + g.ID = ulid.Make().String() + if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { + slog.Error("ui sources: create", "err", err) + s.renderSourceFormError(w, r, u, host, "", true, form, "Couldn't create — see the server log for details.") + return + } + } else { + if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { + slog.Error("ui sources: update", "err", err) + s.renderSourceFormError(w, r, u, host, gid, false, form, "Couldn't save — see the server log for details.") + return + } + } + s.pushScheduleSetAsync(host.ID) + + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther) +} + +func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + gid := chi.URLParam(r, "gid") + + using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), gid) + if err != nil { + slog.Error("ui sources: usage check", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(using) > 0 { + // Shouldn't happen via the UI (delete button is disabled when + // in use); guard anyway against form-replay / curl. + stdhttp.Error(w, "remove this group from its schedules first", stdhttp.StatusConflict) + return + } + + if err := s.deps.Store.DeleteSourceGroup(r.Context(), host.ID, gid); err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui sources: delete", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + s.pushScheduleSetAsync(host.ID) + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther) +} + +// renderSourceFormError re-renders the edit form with the user's +// typed input intact + an error banner. Returns 422 to signal "form +// rejected" while still returning HTML (mirrors handleUIAddHostPost). +func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) { + view := s.baseView(u, "dashboard") + view.Title = "Source group · " + host.Name + " · restic-manager" + saveAction := "/hosts/" + host.ID + "/sources/new" + crumb := "new source group" + if !isNew { + saveAction = "/hosts/" + host.ID + "/sources/" + gid + "/edit" + crumb = form.Name + } + view.Page = sourceGroupEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "sources", crumb), + IsNew: isNew, + GroupID: gid, + Form: form, + SaveAction: saveAction, + Error: msg, + } + w.WriteHeader(stdhttp.StatusUnprocessableEntity) + if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { + slog.Error("ui: render source_group_edit (error)", "err", err) + } +} + +// --- form parsing helpers --- + +func parseSourceForm(v map[string][]string) sourceFormData { + get := func(k string) string { return strings.TrimSpace(firstVal(v, k)) } + rmax, _ := strconv.Atoi(get("retry_max")) + rback, _ := strconv.Atoi(get("retry_backoff_seconds")) + return sourceFormData{ + Name: get("name"), + Includes: firstVal(v, "includes"), // textarea — preserve internal whitespace + Excludes: firstVal(v, "excludes"), + KeepLast: get("keep_last"), + KeepHourly: get("keep_hourly"), + KeepDaily: get("keep_daily"), + KeepWeekly: get("keep_weekly"), + KeepMonthly: get("keep_monthly"), + KeepYearly: get("keep_yearly"), + RetryMax: rmax, + RetryBackoffSeconds: rback, + } +} + +func firstVal(v map[string][]string, k string) string { + if vs, ok := v[k]; ok && len(vs) > 0 { + return vs[0] + } + return "" +} + +// parseKeep maps an empty string → nil pointer (no constraint), +// "0" / "N" → *int. Negative or non-numeric → error. +func parseKeep(s string) (*int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return nil, errors.New("invalid") + } + return &n, nil +} + +func splitLines(s string) []string { + out := []string{} + for _, line := range strings.Split(s, "\n") { + if p := strings.TrimSpace(line); p != "" { + out = append(out, p) + } + } + return out +} + +func formFromGroup(g store.SourceGroup) sourceFormData { + keep := func(p *int) string { + if p == nil { + return "" + } + return strconv.Itoa(*p) + } + return sourceFormData{ + Name: g.Name, + Includes: strings.Join(g.Includes, "\n"), + Excludes: strings.Join(g.Excludes, "\n"), + KeepLast: keep(g.RetentionPolicy.KeepLast), + KeepHourly: keep(g.RetentionPolicy.KeepHourly), + KeepDaily: keep(g.RetentionPolicy.KeepDaily), + KeepWeekly: keep(g.RetentionPolicy.KeepWeekly), + KeepMonthly: keep(g.RetentionPolicy.KeepMonthly), + KeepYearly: keep(g.RetentionPolicy.KeepYearly), + RetryMax: g.RetryMax, + RetryBackoffSeconds: g.RetryBackoffSeconds, + ConflictDimension: g.ConflictDimension, + } +} diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 0956f64..bd26a23 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/styles/input.css b/web/styles/input.css index 3c08daf..d1891d7 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -186,6 +186,32 @@ .host-row.clickable > .row-link { pointer-events: auto; } .host-row.clickable > .row-action { pointer-events: auto; } + /* ---------- source-group rows (Sources tab) ---------- */ + .src-row { + display: grid; align-items: center; + grid-template-columns: 1fr auto; + column-gap: 18px; + padding: 14px 18px; + } + + /* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */ + .keep-cell { + background: var(--bg); + border: 1px solid var(--line-soft); + border-radius: 5px; + padding: 9px 11px; + display: flex; flex-direction: column; gap: 4px; + } + .keep-cell label { + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-fade); + } + .keep-cell input { + background: transparent; border: none; outline: none; + font-family: 'JetBrains Mono', monospace; font-size: 14px; + color: var(--ink); padding: 0; width: 100%; + } + /* ---------- log viewer ---------- */ .log { background: var(--bg); border: 1px solid var(--line-soft); diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html index 9323c2d..6452fff 100644 --- a/web/templates/pages/host_sources.html +++ b/web/templates/pages/host_sources.html @@ -2,12 +2,87 @@ {{define "content"}} {{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}}
-
-

Sources tab — coming next.

-

- The source-group editor lands in P2R-02 slice 2. + +

+

+ Each source group is a named bundle of paths plus the rule for how long its snapshots stick around. + Schedules point at one or more groups — one restic backup runs per group, + tagged by name so forget can apply retention cleanly.

+ + New source group
+ + {{if eq (len $page.Groups) 0}} +
+

No source groups yet.

+

+ Create one to tell the agent what to back up. The group's name doubles as the snapshot tag. +

+ +
+ {{else}} +
+ {{range $i, $row := $page.Groups}} + {{$g := $row.Group}} +
+
+
+ {{$g.Name}} + {{if $g.ConflictDimension}} + keep-{{$g.ConflictDimension}} · cadence mismatch + {{end}} +
+
+ {{len $g.Includes}} include{{if ne (len $g.Includes) 1}}s{{end}} · + {{len $g.Excludes}} exclude{{if ne (len $g.Excludes) 1}}s{{end}} · + {{$g.RetentionPolicy.Summary}} +
+
+ {{if eq $row.UsedBy 0}} + used by 0 schedules + {{else}} + used by {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}} + {{end}} + {{if gt $row.SnapshotCount 0}} · {{$row.SnapshotCount}} snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}} +
+
+
+ {{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}} + + {{else}} + + {{end}} + Edit + {{if gt $row.UsedBy 0}} + + {{else}} +
+ +
+ {{end}} +
+
+ {{end}} +
+ +
+ Run-now on a row dispatches one immediate backup using that group's paths and tag. + Group name is used as the snapshot tag — renaming a group + doesn't retag existing snapshots. +
+ {{end}} +
{{end}} diff --git a/web/templates/pages/source_group_edit.html b/web/templates/pages/source_group_edit.html new file mode 100644 index 0000000..3ef784e --- /dev/null +++ b/web/templates/pages/source_group_edit.html @@ -0,0 +1,132 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +{{$f := $page.Form}} +
+ +

+ {{if $page.IsNew}}New source group{{else}}Edit source group · {{$f.Name}}{{end}} +

+

+ What this group covers and how long its snapshots are worth keeping. + Snapshots produced for this group carry the group's name as a tag — + rename with care: existing snapshots keep the old tag and won't get retained + by a renamed group's policy. +

+ + {{if $page.Error}} +
+ {{$page.Error}} +
+ {{end}} + +
+ +
+ +

Identity

+
+ + +
Used as the snapshot tag. Lowercase, no spaces; matches what restic forget --tag sees.
+
+ +

Paths

+
+ + +
What restic backup walks. Agent runs as root with CAP_DAC_READ_SEARCH, so any readable path is fair game.
+
+
+ + +
Passed straight through as --exclude args.
+
+ +

+ Retention + applied nightly · all blank = keep everything +

+ + {{if and (not $page.IsNew) $f.ConflictDimension}} +
+
+
+ keep-{{$f.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket. + Either drop keep-{{$f.ConflictDimension}} or add a finer-grained schedule. +
+
+ {{end}} + +
+
+
+
+
+
+
+
+
+ Blank fields stay unset (no constraint on that bucket). Forget runs nightly on the cadence configured on the + Repo tab. +
+ +

+ Retry on offline + cron-fired runs only +

+
+
+ + +
+
+ + +
+
+
+ Each retry doubles the wait. Manual run-now ignores this — it just fails immediately if the agent is offline. +
+ +
+ + Cancel +
+
+ + + +
+
+{{end}} -- 2.52.0 From dede74fd3ab109f572797e10a0bc627904ced99c Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 11:49:17 +0100 Subject: [PATCH 03/16] P2R-02 slice 2 follow-up: refuse to delete a host's last source group Belt-and-braces: the UI now disables the Delete button when a group is the only one on the host (with a tooltip explaining why), and the server-side handler returns 409 if a curl/form-replay tries anyway. Every host needs at least one source group to be backup-able, so the 'last group on a fresh host' case is a meaningful accident to guard against. --- internal/server/http/ui_sources.go | 14 ++++++++++++++ web/templates/pages/host_sources.html | 3 +++ 2 files changed, 17 insertions(+) diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go index 8d48c90..cdcd978 100644 --- a/internal/server/http/ui_sources.go +++ b/internal/server/http/ui_sources.go @@ -310,6 +310,20 @@ func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp. return } + // Refuse to delete the host's last source group — every host + // needs at least one to be backup-able. UI disables the button + // in this case; this guards against form-replay / curl. + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui sources: count groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if len(groups) <= 1 { + stdhttp.Error(w, "this is the host's only source group — create another one first", stdhttp.StatusConflict) + return + } + if err := s.deps.Store.DeleteSourceGroup(r.Context(), host.ID, gid); err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html index 6452fff..80abfc7 100644 --- a/web/templates/pages/host_sources.html +++ b/web/templates/pages/host_sources.html @@ -66,6 +66,9 @@ {{if gt $row.UsedBy 0}} + {{else if eq (len $page.Groups) 1}} + {{else}}
-- 2.52.0 From 67ca769686e77dba26801a8c4ea36aadb09a215b Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 11:55:16 +0100 Subject: [PATCH 04/16] =?UTF-8?q?P2R-02=20slice=203:=20Schedules=20tab=20?= =?UTF-8?q?=E2=80=94=20slim=20list,=20new/edit=20form,=20delete,=20Run-now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules list: status (enabled/paused) + cron + source-group tags + actions (Run-now when enabled+online, Edit, Delete). Run-now reuses dispatchScheduledJob — same path real cron fires take, so each referenced source group runs as its own backup with its own tag. Falls back to a 409 if the agent is offline. Schedule new/edit form: cron input with five preset chips (quick-pick @hourly / nightly / 6h / weekly / monthly), source-group multi-pick rendered as styled checkbox cards (visual state tracks the underlying box via a tiny inline script), enabled toggle. No paths/excludes/retention/kind on the schedule itself — those live on source groups now. Server-side validation re-renders with the operator's input + ticked groups intact. Every successful mutation calls pushScheduleSetAsync. Adds .schd-row, .preset-chip, .picker styles. --- internal/server/http/ui_schedules.go | 320 +++++++++++++++++++++++- web/static/css/styles.css | 2 +- web/styles/input.css | 58 +++++ web/templates/pages/host_schedules.html | 68 ++++- web/templates/pages/schedule_edit.html | 116 +++++++++ 5 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 web/templates/pages/schedule_edit.html diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index d957bbb..1d0c487 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -1,23 +1,49 @@ package http import ( + "context" "errors" "log/slog" stdhttp "net/http" + "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // ui_schedules.go — HTML form-driven schedule CRUD against the slim -// shape (cron + source-group multi-select + enabled). The list view -// is live as of slice 1 of P2R-02; the new/edit/delete/run handlers -// land in slice 3. +// shape (cron + source-group multi-select + enabled). -// hostSchedulesPage is the data the schedules-tab template renders. +// hostSchedulesPage backs the list view. GroupNames maps source-group +// ID → name for the per-row tag rendering, populated once on load so +// the template doesn't need to do per-row store lookups. type hostSchedulesPage struct { hostChromeData + Schedules []store.Schedule + GroupNames map[string]string +} + +// scheduleFormData mirrors the form's wire shape — strings + bool for +// round-trip on validation re-render. +type scheduleFormData struct { + CronExpr string + Enabled bool +} + +// scheduleEditPage backs both the new and edit form views. +type scheduleEditPage struct { + hostChromeData + IsNew bool + ScheduleID string // empty when IsNew + Form scheduleFormData + AvailableGroups []store.SourceGroup + SelectedGroupIDs map[string]bool // gid → checked + SaveAction string + Error string } func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -29,10 +55,33 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ if !ok { return } + scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedules: list", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedules: list groups", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + names := make(map[string]string, len(groups)) + for _, g := range groups { + names[g.ID] = g.Name + } + + chrome := s.loadHostChrome(r, *host, "schedules", "schedules") + chrome.ScheduleCount = len(scheds) + chrome.SourceGroupCount = len(groups) + view := s.baseView(u, "dashboard") view.Title = host.Name + " schedules · restic-manager" view.Page = hostSchedulesPage{ - hostChromeData: s.loadHostChrome(r, *host, "schedules", "schedules"), + hostChromeData: chrome, + Schedules: scheds, + GroupNames: names, } if err := s.deps.UI.Render(w, "host_schedules", view); err != nil { slog.Error("ui: render host_schedules", "err", err) @@ -41,23 +90,274 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ } func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule new: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(u, "dashboard") + view.Title = "New schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"), + IsNew: true, + Form: scheduleFormData{Enabled: true}, + AvailableGroups: groups, + SelectedGroupIDs: map[string]bool{}, + SaveAction: "/hosts/" + host.ID + "/schedules/new", + } + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit (new)", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui schedule edit: get", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule edit: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + selected := make(map[string]bool, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + selected[gid] = true + } + view := s.baseView(u, "dashboard") + view.Title = "Edit schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"), + IsNew: false, + ScheduleID: sid, + Form: scheduleFormData{ + CronExpr: sc.CronExpr, + Enabled: sc.Enabled, + }, + AvailableGroups: groups, + SelectedGroupIDs: selected, + SaveAction: "/hosts/" + host.ID + "/schedules/" + sid + "/edit", + } + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } } +// handleUIScheduleSave handles both create and update. On validation +// error, re-renders with input intact + a banner. func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + sid := chi.URLParam(r, "sid") + isNew := sid == "" + + form := scheduleFormData{ + CronExpr: strings.TrimSpace(r.PostForm.Get("cron")), + Enabled: r.PostForm.Get("enabled") == "1", + } + pickedIDs := r.PostForm["source_group_ids"] + selected := make(map[string]bool, len(pickedIDs)) + for _, gid := range pickedIDs { + selected[gid] = true + } + + // --- validation --- + var errMsg string + switch { + case form.CronExpr == "": + errMsg = "Cron expression is required." + case len(pickedIDs) == 0: + errMsg = "Pick at least one source group — a schedule has to know what to back up." + } + if errMsg == "" { + if _, err := cronParser.Parse(form.CronExpr); err != nil { + errMsg = "Cron didn't parse: " + err.Error() + } + } + // Verify every picked group belongs to this host. + if errMsg == "" { + for _, gid := range pickedIDs { + g, gerr := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid) + if gerr != nil || g == nil { + errMsg = "One of the picked source groups isn't on this host — refresh and try again." + break + } + } + } + + if errMsg != "" { + s.renderScheduleFormError(w, r, u, host, sid, isNew, form, selected, errMsg) + return + } + + sc := store.Schedule{ + ID: sid, + HostID: host.ID, + CronExpr: form.CronExpr, + Enabled: form.Enabled, + SourceGroupIDs: pickedIDs, + } + if isNew { + sc.ID = ulid.Make().String() + if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil { + slog.Error("ui schedule save: create", "err", err) + s.renderScheduleFormError(w, r, u, host, "", true, form, selected, + "Couldn't create — see the server log for details.") + return + } + } else { + if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil { + slog.Error("ui schedule save: update", "err", err) + s.renderScheduleFormError(w, r, u, host, sid, false, form, selected, + "Couldn't save — see the server log for details.") + return + } + } + s.pushScheduleSetAsync(host.ID) + + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + if err := s.deps.Store.DeleteSchedule(r.Context(), host.ID, sid); err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui schedule delete", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + s.pushScheduleSetAsync(host.ID) + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther) } +// handleUIScheduleRun is the per-schedule Run-now action: dispatch +// every source group the schedule references in a single shot, +// reusing dispatchScheduledJob (the same path real cron fires take). +// HTMX only — falls back to a 405 for non-HTMX callers (per-group +// Run-now via the Sources tab is the JSON path). func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "schedule editor lands in P2R-02 slice 3", stdhttp.StatusNotImplemented) + if u := s.requireUIUser(w, r); u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + sid := chi.URLParam(r, "sid") + sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if !sc.Enabled { + stdhttp.Error(w, "schedule is paused — enable it first or use per-group Run-now from the Sources tab", + stdhttp.StatusConflict) + return + } + if s.deps.Hub == nil { + stdhttp.Error(w, "ws hub not configured", stdhttp.StatusServiceUnavailable) + return + } + conn := s.deps.Hub.Conn(host.ID) + if conn == nil { + stdhttp.Error(w, "host is offline — reconnect the agent and try again", + stdhttp.StatusConflict) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + s.dispatchScheduledJob(ctx, host.ID, conn, sid, time.Now().UTC()) + + if wantsHTML(r) { + // HX-Redirect would jump to a single job log, but a multi-group + // fire produces N jobs. Bounce back to the list — the operator + // can drill into individual jobs from there. + w.Header().Set("HX-Redirect", "/hosts/"+host.ID+"/schedules") + } + w.WriteHeader(stdhttp.StatusNoContent) +} + +func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, sid string, isNew bool, form scheduleFormData, selected map[string]bool, msg string) { + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err != nil { + slog.Error("ui schedule re-render: list groups", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + saveAction := "/hosts/" + host.ID + "/schedules/new" + crumb := "new schedule" + if !isNew { + saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit" + crumb = "edit schedule" + } + view := s.baseView(u, "dashboard") + view.Title = "Schedule · " + host.Name + " · restic-manager" + view.Page = scheduleEditPage{ + hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb), + IsNew: isNew, + ScheduleID: sid, + Form: form, + AvailableGroups: groups, + SelectedGroupIDs: selected, + SaveAction: saveAction, + Error: msg, + } + w.WriteHeader(stdhttp.StatusUnprocessableEntity) + if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil { + slog.Error("ui: render schedule_edit (error)", "err", err) + } } // loadHostForUI is a small helper shared across the host-detail tab diff --git a/web/static/css/styles.css b/web/static/css/styles.css index bd26a23..424a27a 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{grid-template-columns:1fr auto;padding:14px 18px}.schd-row,.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid}.schd-row{font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/styles/input.css b/web/styles/input.css index d1891d7..a49183d 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -194,6 +194,64 @@ padding: 14px 18px; } + /* ---------- schedule rows (Schedules tab) ---------- */ + .schd-row { + display: grid; align-items: center; + grid-template-columns: 90px 1fr 2fr auto; + column-gap: 18px; + padding: 12px 18px; font-size: 13px; + } + .schd-row.head { + padding-top: 10px; padding-bottom: 10px; + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.08em; + } + + /* ---------- cron preset chips ---------- */ + .preset-chip { + font-family: 'JetBrains Mono', monospace; font-size: 11.5px; + padding: 4px 9px; border-radius: 4px; + border: 1px solid var(--line-soft); color: var(--ink-mid); + background: var(--bg); + cursor: pointer; user-select: none; + transition: border-color 100ms ease, color 100ms ease; + } + .preset-chip:hover { border-color: var(--accent); color: var(--ink); } + + /* ---------- source-group picker (Schedule new/edit) ---------- */ + .picker { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--line-soft); + border-radius: 5px; + font-size: 13px; cursor: pointer; + transition: border-color 100ms ease, background 100ms ease; + } + .picker:hover { border-color: var(--ink-mute); } + .picker .check { + display: inline-block; width: 14px; height: 14px; + border: 1px solid var(--line); border-radius: 3px; + flex-shrink: 0; position: relative; + } + .picker.checked { + border-color: color-mix(in oklch, var(--accent), transparent 50%); + background: color-mix(in oklch, var(--accent), transparent 92%); + } + .picker.checked .check { + background: var(--accent); border-color: var(--accent); + } + .picker.checked .check::after { + content: ""; position: absolute; + left: 4px; top: 1px; width: 4px; height: 8px; + border: solid oklch(0.18 0.01 195); + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); + } + .picker input[type="checkbox"] { + position: absolute; opacity: 0; pointer-events: none; + } + /* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */ .keep-cell { background: var(--bg); diff --git a/web/templates/pages/host_schedules.html b/web/templates/pages/host_schedules.html index 039ac25..74c70f8 100644 --- a/web/templates/pages/host_schedules.html +++ b/web/templates/pages/host_schedules.html @@ -2,12 +2,72 @@ {{define "content"}} {{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +{{$groupNames := $page.GroupNames}}
-
-

Schedules tab — coming next.

-

- The slim-schedule list and form land in P2R-02 slice 3. + +

+

+ A schedule is a cron expression pointing at one or more source groups. When it fires, the agent runs a separate + restic backup per chosen group — independent jobs, independent snapshots, + independent retention. Failure of one group doesn't fail the others.

+ + New schedule
+ + {{if eq (len $page.Schedules) 0}} +
+

No schedules yet.

+

+ Add one and the agent will start running backups on whatever cron expression you give it. + Until then, Run-now from the Sources tab is the only way to trigger a backup. +

+ +
+ {{else}} +
+
+
Status
+
Cron
+
Sources
+
+
+ {{range $i, $sc := $page.Schedules}} +
+
+ {{if $sc.Enabled}} + enabled + {{else}} + paused + {{end}} +
+
{{$sc.CronExpr}}
+
+ {{range $sc.SourceGroupIDs}} + {{$name := index $groupNames .}} + {{if $name}}{{$name}}{{else}}unknown{{end}} + {{end}} +
+
+ {{if and $sc.Enabled (eq $host.Status "online")}} + + {{end}} + Edit + + + +
+
+ {{end}} +
+ {{end}} +
{{end}} diff --git a/web/templates/pages/schedule_edit.html b/web/templates/pages/schedule_edit.html new file mode 100644 index 0000000..07a32e8 --- /dev/null +++ b/web/templates/pages/schedule_edit.html @@ -0,0 +1,116 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +{{$f := $page.Form}} +
+ +

+ {{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}} +

+ + {{if $page.Error}} +
+ {{$page.Error}} +
+ {{end}} + +
+
+ +
+

When

+ + +
+ {{range list "0 3 * * *" "@hourly" "0 */6 * * *" "0 3 * * 0" "0 3 1 * *"}} + {{.}} + {{end}} +
+
+ Standard 5-field cron with descriptors. Server validates with the same parser the agent uses to fire — what saves here is what runs. +
+ +

+ What — pick one or more source groups +

+ {{if eq (len $page.AvailableGroups) 0}} +
+ This host has no source groups yet — create one first + so this schedule has something to back up. +
+ {{else}} +
+ {{range $page.AvailableGroups}} + {{$checked := index $page.SelectedGroupIDs .ID}} + + {{end}} +
+
+ Each picked group runs as a separate restic backup with its own tag — its own snapshot, its own retention. Pick multiple to fire them all on the same cron tick. +
+ {{end}} + +

Status

+ + +
+ + Cancel +
+
+ + +
+
+
+ + +{{end}} -- 2.52.0 From 64d2fcf7a3aa23332bb1ec608ec83c6e288982a9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 12:01:55 +0100 Subject: [PATCH 05/16] P2R-02 follow-up: clickable rows on Sources/Schedules + cron-preset tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns Sources and Schedules tab rows with the dashboard's row-click UX: whole-row click navigates to the row's edit page (mirroring .host-row.clickable). Drops the redundant Edit buttons; Run-now and Delete remain in .row-action cells that sit above the row-link overlay via z-index. Schedule edit form's cron preset chips now carry human-readable title= tooltips ("Every day at 03:00", "Every Sunday at 03:00", etc). tasks.md gets a binding row-design rule covering all current and future list-row templates, and the P2R-02 entry is split into the six slices already agreed with the operator (slices 1–3 marked done, 4 next). --- CLAUDE.md | 8 ++++++++ internal/server/http/p2r01_test.go | 10 +++++++-- tasks.md | 27 +++++++++++++++++-------- web/static/css/styles.css | 2 +- web/styles/input.css | 22 ++++++++++++++++++++ web/templates/pages/host_schedules.html | 6 +++--- web/templates/pages/host_sources.html | 6 +++--- web/templates/pages/schedule_edit.html | 13 +++++++++--- 8 files changed, 74 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a16a3c..c623059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ Project-specific rules for Claude when working in this repo. +## Run `go vet` before every commit + +CI runs `go vet ./...` and will fail the build on any vet error. +Run it locally before staging a commit and fix anything it flags. +A common one is `res, _ := http.Do(...); defer res.Body.Close()` — +if `err != nil` then `res` may be nil and the deferred close +panics. Always check the error before touching `res`. + ## No `Co-Authored-By` trailers on commits Don't add `Co-Authored-By: Claude ...` (or any other co-author diff --git a/internal/server/http/p2r01_test.go b/internal/server/http/p2r01_test.go index 01bca60..7042e1f 100644 --- a/internal/server/http/p2r01_test.go +++ b/internal/server/http/p2r01_test.go @@ -439,7 +439,10 @@ func TestRunSourceGroupOfflineHost(t *testing.T) { url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil) req.AddCookie(cookie) req.Header.Set("Accept", "application/json") - res, _ := stdhttp.DefaultClient.Do(req) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } defer res.Body.Close() if res.StatusCode != stdhttp.StatusServiceUnavailable { t.Errorf("offline: want 503, got %d", res.StatusCode) @@ -456,7 +459,10 @@ func TestRunSourceGroupUnknownGroup(t *testing.T) { url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil) req.AddCookie(cookie) req.Header.Set("Accept", "application/json") - res, _ := stdhttp.DefaultClient.Do(req) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNotFound { t.Errorf("unknown group: want 404, got %d", res.StatusCode) diff --git a/tasks.md b/tasks.md index 318f287..06e6a3b 100644 --- a/tasks.md +++ b/tasks.md @@ -142,16 +142,27 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - **Auto-init at enrolment**: server dispatches `restic init` on first WS connect (was P2-old "Init repo" button — now invisible to the operator). On success: emit a normal job row with `kind=init` so the audit trail still shows it. On `init` returning "config file already exists" (e.g. re-enrolment against an existing repo): treat as soft success per existing restic-wrapper behaviour. - **Tests**: rewrite the deleted `schedules_test.go` and `schedule_push_test.go` against new endpoints; new `source_groups_test.go`, `repo_maintenance_test.go`, `auto_init_test.go`. End-to-end: enrol → server pushes creds → server dispatches init → agent runs it → schedule reconcile fires → operator hits per-source-group Run-now → backup runs → snapshots refresh. -### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) — TODO +### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) — IN PROGRESS + +> **Row-design rule (binding for every list-row template in this app, current and future):** +> Whole-row click navigates to the row's primary detail/edit page — +> mirror `.host-row.clickable` on the dashboard +> (`partials/host_row.html`): an absolute-positioned `.row-link` +> overlay with `text-indent: -9999px` covers the row, action buttons +> live in `.row-action` cells that sit above via z-index. **Do not +> add an explicit "Edit" button** when the row is clickable — it +> duplicates the affordance and dilutes the click target. Action +> cells are reserved for verbs that aren't "open this row" (Run-now, +> Delete, Pause, etc). - [ ] **P2R-02** (L) UI templates rebuilt against the new model: - - `/hosts/{id}/sources` — list of source groups with per-row meta (includes/excludes count, retention summary via `RetentionPolicy.Summary()`, usage = which schedules reference this group, snapshot count for `tag = group.name`). Run-now / Edit / Delete actions per row. - - `/hosts/{id}/sources/{gid}/edit` (and `/sources/new`) — name (= snapshot tag), includes/excludes textareas, retention as a 3×2 keep-* grid, retry-on-offline, inline conflict banner above retention when granularity ↔ cadence mismatch detected (uses `SourceGroup.conflict_dimension` cache). - - `/hosts/{id}/schedules` — slim list (status / cron / source-tags / actions) plus new-schedule form (cron with quick-pick chips, source-group multi-select via clickable check pickers, enabled toggle). - - `/hosts/{id}/repo` — connection (URL/user/password — pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; cert pin), bandwidth caps (host-wide), maintenance rows (forget/prune/check cadence + check subset %), danger-zone re-init. - - **Re-enable the four host-detail sub-tabs** (Snapshots is already live; Schedules / Sources / Repo become real links again; Settings stays inert until later). Drop the stop-gap inert-div hack from P2R-00.4. - - **Per-source-group Run-now buttons** replace today's per-host `Run backup now` buttons (right-rail + dashboard row + empty-snapshots state). Dashboard row's Run-now becomes either "Run all groups" (if exactly one schedule covers all groups) or "Open →" (multi-group hosts). - - Header "version N · agent in sync / agent at vM" indicator preserved (still backed by `host_schedule_version` + `applied_schedule_version`). + - **Slice 1 ✅** Sub-tab navigation skeleton — extract header/vitals/sub-tabs into a `host_chrome` partial; Sources / Schedules / Repo become real `` links; placeholder pages share the chrome; version indicator restored. (commit `a535822`) + - **Slice 2 ✅** Sources tab — `/hosts/{id}/sources` list with per-row meta + clickable rows + per-group Run-now/Delete; `/sources/new` and `/sources/{gid}/edit` form (name, includes/excludes, 3×2 keep-* grid, retry-on-offline, inline conflict banner from `ConflictDimension` cache); validation re-renders form with input intact; refuses to delete a host's last source group. (commits `0ed9c3d`, `dede74f`) + - **Slice 3 ✅** Schedules tab — `/hosts/{id}/schedules` slim list (status / cron / source-tags / actions, clickable rows) plus `/schedules/new` and `/schedules/{sid}/edit` form (cron with five quick-pick chips that have human-readable tooltips, source-group multi-pick as styled check cards, enabled toggle); per-schedule Run-now reuses `dispatchScheduledJob`. (commit `67ca769` + clickable-row follow-up) + - **Slice 4 (next)** `/hosts/{id}/repo` — connection (URL/user/password — pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; cert pin), bandwidth caps (host-wide; new `PUT /api/hosts/{id}/bandwidth`), maintenance rows (forget/prune/check cadence + check subset %), danger-zone re-init. + - **Slice 5** Per-source-group Run-now wiring on dashboard row + empty-snapshots state. Dashboard row's Run-now becomes either "Run all groups" (if exactly one schedule covers all groups) or "Open →" (multi-group hosts). + - **Slice 6** Playwright sweep — login → walk new tabs → create source group → create schedule → run-now → confirm dispatch. + - Header "version N · agent in sync / agent at vM" indicator preserved across all tabs (backed by `host_schedule_version` + `applied_schedule_version`). - Form validation re-renders with the operator's typed input intact (mirror P2-04's behaviour). Each save fires `pushScheduleSetAsync` so an online agent re-arms within seconds. ### P2 redesign — Phase 5 (server-side maintenance ticker) — TODO diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 424a27a..17f1f2b 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{grid-template-columns:1fr auto;padding:14px 18px}.schd-row,.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid}.schd-row{font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/styles/input.css b/web/styles/input.css index a49183d..565c5e2 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -193,6 +193,18 @@ column-gap: 18px; padding: 14px 18px; } + /* Whole-row click → edit page, mirroring .host-row.clickable on the + dashboard. Action cells sit above via z-index so their buttons + keep working. */ + .src-row.clickable { position: relative; } + .src-row.clickable .row-link { + position: absolute; inset: 0; z-index: 0; + text-indent: -9999px; overflow: hidden; + } + .src-row.clickable:hover { background: var(--panel-hi); cursor: pointer; } + .src-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .src-row.clickable > .row-link { pointer-events: auto; } + .src-row.clickable > .row-action { pointer-events: auto; } /* ---------- schedule rows (Schedules tab) ---------- */ .schd-row { @@ -206,6 +218,16 @@ font-size: 11px; color: var(--ink-fade); text-transform: uppercase; letter-spacing: 0.08em; } + /* Whole-row click → edit page (matches .host-row.clickable). */ + .schd-row.clickable { position: relative; } + .schd-row.clickable .row-link { + position: absolute; inset: 0; z-index: 0; + text-indent: -9999px; overflow: hidden; + } + .schd-row.clickable:hover { background: var(--panel-hi); cursor: pointer; } + .schd-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .schd-row.clickable > .row-link { pointer-events: auto; } + .schd-row.clickable > .row-action { pointer-events: auto; } /* ---------- cron preset chips ---------- */ .preset-chip { diff --git a/web/templates/pages/host_schedules.html b/web/templates/pages/host_schedules.html index 74c70f8..541c45a 100644 --- a/web/templates/pages/host_schedules.html +++ b/web/templates/pages/host_schedules.html @@ -36,7 +36,8 @@
{{range $i, $sc := $page.Schedules}} -
+
+ edit
{{if $sc.Enabled}} enabled @@ -51,14 +52,13 @@ {{if $name}}{{$name}}{{else}}unknown{{end}} {{end}}
-
+
{{if and $sc.Enabled (eq $host.Status "online")}} {{end}} - Edit
diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html index 80abfc7..36a8077 100644 --- a/web/templates/pages/host_sources.html +++ b/web/templates/pages/host_sources.html @@ -29,7 +29,8 @@
{{range $i, $row := $page.Groups}} {{$g := $row.Group}} -
+
+ {{$g.Name}}
{{$g.Name}} @@ -52,7 +53,7 @@ {{if gt $row.SnapshotCount 0}} · {{$row.SnapshotCount}} snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}}
-
+
{{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}} {{end}} - Edit {{if gt $row.UsedBy 0}} diff --git a/web/templates/pages/schedule_edit.html b/web/templates/pages/schedule_edit.html index 07a32e8..9dc9757 100644 --- a/web/templates/pages/schedule_edit.html +++ b/web/templates/pages/schedule_edit.html @@ -26,9 +26,16 @@
- {{range list "0 3 * * *" "@hourly" "0 */6 * * *" "0 3 * * 0" "0 3 1 * *"}} - {{.}} - {{end}} + 0 3 * * * + @hourly + 0 */6 * * * + 0 3 * * 0 + 0 3 1 * *
Standard 5-field cron with descriptors. Server validates with the same parser the agent uses to fire — what saves here is what runs. -- 2.52.0 From 8b91d3037cf6b3a2c1958a7b9a50f435eb42142e Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 12:07:26 +0100 Subject: [PATCH 06/16] P2R-02 follow-up: Run-now works on disabled schedules with confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the Run-now button on every schedule when the host is online, not just enabled ones. Disabled rows render the button as a non-primary style + a HX-confirm dialog ("This schedule is paused — running it now won't change that. Fire it once anyway?"); enabled rows keep the zero-friction primary button. Server-side, Run-now no longer short-circuits on !Enabled — it dispatches the source groups inline rather than via dispatchScheduledJob (which always bails on disabled schedules, since cron-tick semantics are different from explicit operator intent). The audit-log entry inside dispatchBackupForGroup still records every fire. --- internal/server/http/ui_schedules.go | 22 ++++++++++++++++++---- web/templates/pages/host_schedules.html | 21 ++++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 1d0c487..8352313 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -301,9 +301,8 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - if !sc.Enabled { - stdhttp.Error(w, "schedule is paused — enable it first or use per-group Run-now from the Sources tab", - stdhttp.StatusConflict) + if len(sc.SourceGroupIDs) == 0 { + stdhttp.Error(w, "this schedule has no source groups attached", stdhttp.StatusConflict) return } if s.deps.Hub == nil { @@ -316,9 +315,24 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques stdhttp.StatusConflict) return } + + // Manual Run-now ignores Enabled. "Disabled" only suppresses + // cron-tick firing; an ad-hoc one-off run is a separate intent + // (and the dispatch is audit-logged inside dispatchBackupForGroup). + // We dispatch inline rather than calling dispatchScheduledJob, + // which short-circuits on !Enabled. ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - s.dispatchScheduledJob(ctx, host.ID, conn, sid, time.Now().UTC()) + now := time.Now().UTC() + for _, gid := range sc.SourceGroupIDs { + g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid) + if gerr != nil { + slog.Warn("ui schedule run: load source group", + "host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr) + continue + } + s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + } if wantsHTML(r) { // HX-Redirect would jump to a single job log, but a multi-group diff --git a/web/templates/pages/host_schedules.html b/web/templates/pages/host_schedules.html index 541c45a..764ae2d 100644 --- a/web/templates/pages/host_schedules.html +++ b/web/templates/pages/host_schedules.html @@ -53,11 +53,22 @@ {{end}}
- {{if and $sc.Enabled (eq $host.Status "online")}} - + {{if eq $host.Status "online"}} + {{if $sc.Enabled}} + + {{else}} + + {{end}} + {{else}} + {{end}} -- 2.52.0 From d62b173712f2d3cfa406cb43e15dff42f2c6b96a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 12:14:03 +0100 Subject: [PATCH 07/16] =?UTF-8?q?P2R-02=20slice=204:=20Repo=20tab=20?= =?UTF-8?q?=E2=80=94=20connection=20/=20bandwidth=20/=20maintenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent forms on /hosts/{id}/repo so saving one section doesn't disturb the others: * Connection: edits repo URL, username, password (pre-filled from the redacted GET /api/hosts/{id}/repo-credentials view; password field shows masked stored-creds placeholder; blank password = keep existing). On save, encrypts and pushes config.update to a connected agent. * Bandwidth: host-wide upload/download caps (KB/s; blank = no cap) written via store.SetHostBandwidth. New REST endpoint PUT /api/hosts/{id}/bandwidth for JSON callers. * Maintenance: forget/prune/check cadences + check subset %, with per-row enabled toggles. Reuses cronParser for validation; auto-seeds the row if a host pre-dates the migration. Right-rail surfaces repo size, snapshot count, snapshots-by-tag breakdown (counted from existing snapshot tag rows), and an 'untagged snapshots are left alone' note. Danger-zone re-init button is rendered but disabled with a hint pointing at P2R-09 (real implementation lands there). Validation re-renders the page with the relevant form's banner and all other section state intact. Successful saves redirect with a ?saved=
query param so the page surfaces a small ✓ saved indicator on the relevant form. ci.yml: bump golangci-lint-action v6→v7 (separate change picked up in this commit). --- .gitea/workflows/ci.yml | 2 +- internal/server/http/host_bandwidth.go | 65 +++++ internal/server/http/server.go | 10 +- internal/server/http/ui_repo.go | 328 ++++++++++++++++++++++++- web/static/css/styles.css | 2 +- web/templates/pages/host_repo.html | 210 +++++++++++++++- 6 files changed, 602 insertions(+), 15 deletions(-) create mode 100644 internal/server/http/host_bandwidth.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e6ff8d1..091b753 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v7 with: # v1.61 was built against Go 1.23 and refuses to load a # config that targets a newer toolchain — go.mod is on 1.25. diff --git a/internal/server/http/host_bandwidth.go b/internal/server/http/host_bandwidth.go new file mode 100644 index 0000000..6b3172c --- /dev/null +++ b/internal/server/http/host_bandwidth.go @@ -0,0 +1,65 @@ +// host_bandwidth.go — REST API for /api/hosts/{id}/bandwidth. +// +// Host-wide upload/download caps (KB/s). Applied to every restic +// invocation as --limit-upload / --limit-download. Pass null / +// omit a field to clear that cap. +package http + +import ( + "encoding/json" + "errors" + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type hostBandwidthRequest struct { + BandwidthUpKBps *int `json:"bandwidth_up_kbps"` + BandwidthDownKBps *int `json:"bandwidth_down_kbps"` +} + +type hostBandwidthView struct { + BandwidthUpKBps *int `json:"bandwidth_up_kbps"` + BandwidthDownKBps *int `json:"bandwidth_down_kbps"` +} + +func (s *Server) handleUpdateHostBandwidth(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + var req hostBandwidthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.BandwidthUpKBps != nil && *req.BandwidthUpKBps < 0 { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value", + "bandwidth_up_kbps must be non-negative") + return + } + if req.BandwidthDownKBps != nil && *req.BandwidthDownKBps < 0 { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value", + "bandwidth_down_kbps must be non-negative") + return + } + if err := s.deps.Store.SetHostBandwidth(r.Context(), hostID, req.BandwidthUpKBps, req.BandwidthDownKBps); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, stdhttp.StatusOK, hostBandwidthView{ + BandwidthUpKBps: req.BandwidthUpKBps, + BandwidthDownKBps: req.BandwidthDownKBps, + }) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index ecced85..5d09a09 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -126,6 +126,10 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) + // Host-wide bandwidth caps (host.bandwidth_up_kbps / + // bandwidth_down_kbps). Apply to every restic invocation. + r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth) + // Per-source-group Run-now (JSON variant). HTMX action is // mounted at the equivalent path outside /api below — both // resolve to the same handler, which sniffs HX-Request. @@ -192,8 +196,12 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) - // Repo tab (slice 4 fills in body). + // Repo tab — connection / bandwidth / maintenance. Three + // independent forms so saving one doesn't touch the others. r.Get("/hosts/{id}/repo", s.handleUIHostRepo) + r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave) + r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) + r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) // Schedules tab + create/edit/delete forms. r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index df9f48a..b9e334e 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -1,16 +1,130 @@ package http import ( + "encoding/json" + "errors" "log/slog" stdhttp "net/http" + "strconv" + "strings" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // ui_repo.go — HTML form-driven repo-tab handlers (connection, -// bandwidth caps, maintenance cadences, danger-zone re-init). Slice -// 1 of P2R-02 lights the tab; slice 4 fills in the body. +// bandwidth caps, maintenance cadences, danger-zone re-init). Splits +// the page into three independent forms so saving one section +// doesn't disturb the others. +// +// GET /hosts/{id}/repo — render +// POST /hosts/{id}/repo/credentials — connection +// POST /hosts/{id}/repo/bandwidth — host-wide bw caps +// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences type hostRepoPage struct { hostChromeData + + // Connection (redacted view) + RepoURL string + RepoUsername string + HasPassword bool + + // Bandwidth (form values, blank means "no cap") + BandwidthUp string + BandwidthDown string + + // Maintenance row + Maintenance store.HostRepoMaintenance + + // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. + SnapshotsByTag map[string]int + UntaggedSnapshots int + GroupNames []string // ordered, for stable rendering + + // Inline form-error banners. Empty when no error for that section. + CredentialsError string + BandwidthError string + MaintenanceError string + + // Highlight which form was just submitted, for the success-state + // border (subtle UX nicety; empty = no recent save). + SavedSection string +} + +// loadHostRepoPage builds the read-only side of the page state. The +// per-form save handlers re-call this and overlay any banner / saved +// markers before rendering. +func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) { + p := &hostRepoPage{ + hostChromeData: s.loadHostChrome(r, host, "repo", "repo"), + } + + // Credentials (redacted). + enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID) + switch { + case err == nil: + plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID)) + if derr == nil { + var blob repoCredsBlob + if jerr := json.Unmarshal(plain, &blob); jerr == nil { + p.RepoURL = blob.RepoURL + p.RepoUsername = blob.RepoUsername + p.HasPassword = blob.RepoPassword != "" + } + } + case errors.Is(err, store.ErrNotFound): + // no creds yet — leave fields empty + default: + return nil, err + } + + // Bandwidth. + if host.BandwidthUpKBps != nil { + p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps) + } + if host.BandwidthDownKBps != nil { + p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps) + } + + // Maintenance — auto-seed defaults if missing. + m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) + if err != nil && errors.Is(err, store.ErrNotFound) { + if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil { + return nil, seedErr + } + m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) + } + if err != nil { + return nil, err + } + p.Maintenance = *m + + // Snapshot counts by tag — used for the right-rail breakdown. + groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) + if err == nil { + groupNameSet := make(map[string]struct{}, len(groups)) + for _, g := range groups { + p.GroupNames = append(p.GroupNames, g.Name) + groupNameSet[g.Name] = struct{}{} + } + if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil { + p.SnapshotsByTag = make(map[string]int, len(groups)) + for _, sn := range snaps { + matched := false + for _, t := range sn.Tags { + if _, ok := groupNameSet[t]; ok { + p.SnapshotsByTag[t]++ + matched = true + } + } + if !matched { + p.UntaggedSnapshots++ + } + } + } + } + return p, nil } func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -22,13 +136,217 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) if !ok { return } + page, err := s.loadHostRepoPage(r, *host) + if err != nil { + slog.Error("ui repo: load page", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + page.SavedSection = r.URL.Query().Get("saved") view := s.baseView(u, "dashboard") view.Title = host.Name + " repo · restic-manager" - view.Page = hostRepoPage{ - hostChromeData: s.loadHostChrome(r, *host, "repo", "repo"), - } + view.Page = *page if err := s.deps.UI.Render(w, "host_repo", view); err != nil { slog.Error("ui: render host_repo", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } + +// renderRepoFormError loads the page state, overlays the section's +// error / saved marker, and renders. Returns an HTTP status (422 for +// validation, 200 for success). +func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, savedSection, credErr, bwErr, mntErr string, status int) { + page, err := s.loadHostRepoPage(r, *host) + if err != nil { + slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + page.SavedSection = savedSection + page.CredentialsError = credErr + page.BandwidthError = bwErr + page.MaintenanceError = mntErr + view := s.baseView(u, "dashboard") + view.Title = host.Name + " repo · restic-manager" + view.Page = *page + if status != stdhttp.StatusOK { + w.WriteHeader(status) + } + if err := s.deps.UI.Render(w, "host_repo", view); err != nil { + slog.Error("ui: render host_repo", "err", err) + } +} + +// handleUIRepoCredentialsSave updates the host's stored repo URL, +// username, and (optionally) password. Empty password means "leave +// the existing one alone" — passwords are never round-tripped to the +// browser, so a blank field is the only way an operator can save the +// other fields without re-typing the password. +func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + repoURL := strings.TrimSpace(r.PostForm.Get("repo_url")) + repoUser := strings.TrimSpace(r.PostForm.Get("repo_username")) + repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately + + if repoURL == "" { + s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "", stdhttp.StatusUnprocessableEntity) + return + } + + // Merge with existing blob — same semantics as the JSON PUT. + existing := repoCredsBlob{} + if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID); err == nil { + if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil { + _ = json.Unmarshal(plain, &existing) + } + } + existing.RepoURL = repoURL + existing.RepoUsername = repoUser + if repoPass != "" { + existing.RepoPassword = repoPass + } + if existing.RepoPassword == "" { + s.renderRepoPage(w, r, u, host, "", + "No password on file yet — set one before saving the URL/username.", + "", "", stdhttp.StatusUnprocessableEntity) + return + } + + enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID)) + if err != nil { + slog.Error("ui repo creds: encrypt", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, enc); err != nil { + slog.Error("ui repo creds: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { + _ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing) + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther) +} + +// handleUIRepoBandwidthSave updates the host's upload/download caps. +// Empty input → nil pointer → no cap. Negative → error. +func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up")) + down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down")) + if upErr != nil || downErr != nil { + s.renderRepoPage(w, r, u, host, "", "", + "Bandwidth caps must be non-negative whole numbers (or blank for no cap).", + "", stdhttp.StatusUnprocessableEntity) + return + } + if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil { + slog.Error("ui repo bandwidth: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther) +} + +// handleUIRepoMaintenanceSave updates the forget/prune/check +// cadences in one go. Cron expressions parsed with the same parser +// the agent + REST handler use. +func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron")) + pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron")) + checkCron := strings.TrimSpace(r.PostForm.Get("check_cron")) + subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct")) + + for label, expr := range map[string]string{ + "forget": forgetCron, "prune": pruneCron, "check": checkCron, + } { + if expr == "" { + s.renderRepoPage(w, r, u, host, "", "", "", + label+" cadence is required.", stdhttp.StatusUnprocessableEntity) + return + } + if _, err := cronParser.Parse(expr); err != nil { + s.renderRepoPage(w, r, u, host, "", "", "", + label+" cadence didn't parse: "+err.Error(), stdhttp.StatusUnprocessableEntity) + return + } + } + subset, err := strconv.Atoi(subsetStr) + if err != nil || subset < 0 || subset > 100 { + s.renderRepoPage(w, r, u, host, "", "", "", + "check subset % must be between 0 and 100.", stdhttp.StatusUnprocessableEntity) + return + } + + if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil { + slog.Error("ui repo maintenance: seed", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + m := store.HostRepoMaintenance{ + HostID: host.ID, + ForgetCron: forgetCron, + ForgetEnabled: r.PostForm.Get("forget_enabled") == "1", + PruneCron: pruneCron, + PruneEnabled: r.PostForm.Get("prune_enabled") == "1", + CheckCron: checkCron, + CheckEnabled: r.PostForm.Get("check_enabled") == "1", + CheckSubsetPct: subset, + } + if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil { + slog.Error("ui repo maintenance: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther) +} + +// parseOptionalNonNegInt returns (nil, nil) for an empty string, or +// (*int, nil) for a non-negative integer. Negative or non-numeric → +// error. Used for bandwidth caps where blank means "no limit". +func parseOptionalNonNegInt(s string) (*int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return nil, errors.New("invalid") + } + return &n, nil +} diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 17f1f2b..0f2fad1 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index bd3d87d..5580e7f 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -2,13 +2,209 @@ {{define "content"}} {{template "host_chrome" .}} -
-
-

Repo tab — coming next.

-

- Connection settings, bandwidth caps, maintenance cadences, and the - danger-zone re-init land in P2R-02 slice 4. -

+{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+ + {{/* ---------- Connection ---------- */}} +

Connection

+ + {{if $page.CredentialsError}} +
+ {{$page.CredentialsError}} +
+ {{end}} + {{if eq $page.SavedSection "credentials"}} +
✓ saved
+ {{end}} +
+
+ + +
e.g. rest:http://192.168.0.99:8000/{{$host.Name}}/
+
+
+ + +
Sent as the rest-server --htpasswd user.
+
+
+ + +
Stored AEAD-encrypted; pushed to the agent over WS. Leave blank to keep the existing password.
+
+
+
+ +
+ + + {{/* ---------- Bandwidth ---------- */}} +

Bandwidth · host-wide

+
+ {{if $page.BandwidthError}} +
+ {{$page.BandwidthError}} +
+ {{end}} + {{if eq $page.SavedSection "bandwidth"}} +
✓ saved
+ {{end}} +
+
+ + +
+
+ + +
+
+
+ Applies to every backup, restore, and prune job for this host. Maps to restic --limit-upload / --limit-download. +
+
+ +
+
+ + {{/* ---------- Maintenance ---------- */}} +

Maintenance · server-side cadences

+
+ {{if $page.MaintenanceError}} +
+ {{$page.MaintenanceError}} +
+ {{end}} + {{if eq $page.SavedSection "maintenance"}} +
✓ saved
+ {{end}} + + {{$m := $page.Maintenance}} +
+
Verb
+
Cron cadence
+
Notes
+
Enabled
+
+ +
+
forget
+
+
Per source group, using each group's retention policy.
+
+ +
+
+ +
+
prune
+
+
Reclaims storage made dead by forget. Heavy — weekly only.
+
+ +
+
+ +
+
check
+
+
+ --read-data-subset + % +
+
+ +
+
+ +
+ + Server-side ticker drives execution — independent of the agent's cron. +
+
+ + {{/* ---------- Danger zone ---------- */}} +

Danger zone

+
+
+
+
Re-initialise repo
+

+ Tries to DELETE the rest-server's copy of this repo, then runs + restic init against the empty path. Most rest-server setups run with + --append-only and refuse the DELETE — the future P2R-09 flow surfaces + guided cleanup steps in that case. +

+

+ All snapshots are lost; this host's schedule version stays the same and the agent's + secrets.enc is reused. +

+
+ +
+
+ + {{/* ---------- right rail ---------- */}} + +
{{end}} -- 2.52.0 From 4035c44be3f7a019c9fb1b467e30467c9e6a41ae Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 13:25:31 +0100 Subject: [PATCH 08/16] =?UTF-8?q?P2R-02=20follow-up:=20schedule=20Run-now?= =?UTF-8?q?=20feedback=20(single=20=E2=86=92=20job=20log,=20multi=20?= =?UTF-8?q?=E2=86=92=20toast)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules tab Run-now used to silently HX-Redirect back to the list, leaving the operator wondering whether the click registered. Now: * Single-source-group schedule → HX-Redirect to that one job's live log, matching the per-source-group Run-now UX from Sources. * Multi-group schedule → stay on the schedules list and fire a success toast ("N backups dispatched: ") via the existing rm:toast HX-Trigger channel, so the operator sees clear acknowledgement without losing their place. dispatchBackupForGroup now returns the persisted job ID so the caller can choose between job-log redirect and toast feedback; on any internal failure it returns "" and the warning still hits slog as before. The cron-fired path (dispatchScheduledJob) ignores the return value, behaviour unchanged. --- internal/server/http/schedule_push.go | 14 ++++++++--- internal/server/http/ui_schedules.go | 35 +++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index 6c2c692..e82dfc6 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -167,7 +167,12 @@ func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn * // dispatchBackupForGroup builds and sends a single backup command.run // envelope on conn for the given group. Persists the job row first so // the live log viewer can subscribe to it. -func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) { +// dispatchBackupForGroup persists a backup job row, sends the +// command.run envelope to the agent, and audit-logs the dispatch. +// Returns the persisted job ID on success, or "" on any failure +// (failures are slog.Warn-ed). Callers may use the returned ID to, +// e.g., redirect the UI to the live job log. +func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) string { jobID := ulid.Make().String() now := time.Now().UTC() scheduleRef := scheduleID @@ -181,7 +186,7 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host }); err != nil { slog.Warn("schedule.fire: persist job", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "err", err) - return + return "" } // Backup ignores RetentionPolicy — the forget cadence lives on // host_repo_maintenance and is driven by the server-side ticker @@ -196,14 +201,14 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host if err != nil { slog.Warn("schedule.fire: marshal command.run", "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "" } sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := conn.Send(sendCtx, env); err != nil { slog.Warn("schedule.fire: send command.run", "host_id", hostID, "schedule_id", scheduleID, "err", err) - return + return "" } _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ ID: ulid.Make().String(), @@ -216,4 +221,5 @@ func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, host slog.Info("schedule.fire: dispatched backup", "host_id", hostID, "schedule_id", scheduleID, "group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt) + return jobID } diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 8352313..61f9e64 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -2,9 +2,11 @@ package http import ( "context" + "encoding/json" "errors" "log/slog" stdhttp "net/http" + "strconv" "strings" "time" @@ -324,6 +326,8 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() now := time.Now().UTC() + type fired struct{ groupName, jobID string } + dispatched := make([]fired, 0, len(sc.SourceGroupIDs)) for _, gid := range sc.SourceGroupIDs { g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid) if gerr != nil { @@ -331,14 +335,35 @@ func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Reques "host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr) continue } - s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now) + if jobID != "" { + dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID}) + } } if wantsHTML(r) { - // HX-Redirect would jump to a single job log, but a multi-group - // fire produces N jobs. Bounce back to the list — the operator - // can drill into individual jobs from there. - w.Header().Set("HX-Redirect", "/hosts/"+host.ID+"/schedules") + switch len(dispatched) { + case 0: + stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError) + return + case 1: + // Single-group schedule: jump straight to the live job log, + // same UX as per-source-group Run-now from the Sources tab. + w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID) + default: + // Multi-group: stay on the schedules tab and toast the + // summary. Direct the operator to one of the job logs via + // the toast (the most recent job ID is fine). + names := make([]string, 0, len(dispatched)) + for _, f := range dispatched { + names = append(names, f.groupName) + } + msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ") + payload, _ := json.Marshal(map[string]any{ + "rm:toast": map[string]string{"level": "success", "message": msg}, + }) + w.Header().Set("HX-Trigger", string(payload)) + } } w.WriteHeader(stdhttp.StatusNoContent) } -- 2.52.0 From ffba7371c5d5a53cc22fbb2cf59319fa3d1bafc6 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 13:35:18 +0100 Subject: [PATCH 09/16] agent runner: drop status-event spam from log.stream restic --json emits a status frame ~every 16ms during a backup. The runner was forwarding every line to log.stream verbatim, which flooded the live log pane with duplicate status JSON for any short-running backup (visible immediately on a 1000-file, ~4MB test set: ~14 identical 'percent_done: 1' lines in 220ms). The progress widget already covers the same information at a sane sample rate (one per second via job.progress), so the raw status lines in log.stream are double-bookkeeping. Skip them and forward only non-status lines (file names, errors, summary). Throttling logic for job.progress is unchanged. --- internal/agent/runner/runner.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index 62c17f4..e7b1e0b 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -74,19 +74,26 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t lastProgress := time.Now() handle := func(stream string, line string, ev any) { - // Forward every line to the server as log.stream. - now := time.Now().UTC() - logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ - JobID: jobID, - Seq: seq.Add(1), - TS: now, - Stream: api.LogStream(stream), - Payload: line, - }) - _ = r.tx.Send(logEnv) + // Throttled progress events come from restic's `status` JSON. + // We deliberately do NOT forward the raw status line to + // log.stream — it's emitted ~every 16ms by restic --json and + // would drown the live log in dupes for any short backup. The + // progress widget already covers the same information at a + // sane sample rate. + status, isStatus := ev.(restic.BackupStatus) + if !isStatus { + now := time.Now().UTC() + logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ + JobID: jobID, + Seq: seq.Add(1), + TS: now, + Stream: api.LogStream(stream), + Payload: line, + }) + _ = r.tx.Send(logEnv) + } - // Throttled progress events. - if status, ok := ev.(restic.BackupStatus); ok { + if isStatus { if time.Since(lastProgress) < r.progressMinPeriod { return } -- 2.52.0 From fab99b4a38da04f1f0399e941c34007be4112c46 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 13:42:50 +0100 Subject: [PATCH 10/16] P2R-02 slice 5: dashboard row Run-now uses covering schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder 'Open →' link with a per-host Run-now decision computed server-side once per render: * If the host has exactly one enabled schedule whose source-group set covers every group on the host → primary 'Run all groups' button (HX-POST to that schedule's /run endpoint, fires every backup the host knows about in one click). * Otherwise (zero matches, multiple matches, or any ambiguity) → ghost 'Open →' link to /hosts/{id}/sources, where the operator picks per-group from the source-group rows. dashboardPage.Hosts moves from []store.Host to []dashboardHostRow to carry the precomputed RunAllScheduleID; host_row.html now reads .Host.* and .RunAllScheduleID. Two extra store calls per host on dashboard render — fine at fleet sizes we care about; if we ever need to support thousands of hosts we'll batch these queries. --- internal/server/http/ui_handlers.go | 75 +++++++++++++++++++++++++++- web/templates/partials/host_row.html | 66 +++++++++++++----------- 2 files changed, 110 insertions(+), 31 deletions(-) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 1c2b728..755e8fd 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -103,11 +103,64 @@ func (s *Server) version() string { // dashboardPage is the data the dashboard template renders against. type dashboardPage struct { - Hosts []store.Host + Hosts []dashboardHostRow HostCount int Summary store.FleetSummary } +// dashboardHostRow carries a host plus the per-row Run-now decision +// the host_row partial needs. The decision is computed server-side +// once per render rather than recomputed in the template. +type dashboardHostRow struct { + Host store.Host + // RunAllScheduleID is the ID of the single schedule that covers + // every source group on the host. Empty when zero or 2+ schedules + // match — in that case the row shows "Open →" instead of a Run-now + // button (the operator picks per-group from the host detail). + RunAllScheduleID string +} + +// pickRunAllSchedule returns the ID of the single schedule whose +// source-group set ⊇ every source group on the host. Returns "" when +// zero or 2+ such "covering" schedules exist (operator-disambiguation +// belongs on the host detail, not the dashboard one-click). +func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string { + if len(groups) == 0 || len(scheds) == 0 { + return "" + } + groupIDs := make(map[string]struct{}, len(groups)) + for _, g := range groups { + groupIDs[g.ID] = struct{}{} + } + matched := "" + for _, sc := range scheds { + if !sc.Enabled { + continue + } + // Treat sc.SourceGroupIDs as a set; check it covers every group. + got := make(map[string]struct{}, len(sc.SourceGroupIDs)) + for _, gid := range sc.SourceGroupIDs { + got[gid] = struct{}{} + } + covers := true + for gid := range groupIDs { + if _, ok := got[gid]; !ok { + covers = false + break + } + } + if !covers { + continue + } + if matched != "" { + // Two distinct covering schedules — ambiguous, bail out. + return "" + } + matched = sc.ID + } + return matched +} + // handleUIDashboard is the root page. Auth-gated; falls through to // /login if there is no session. func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -129,10 +182,28 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) return } + // Per-host: pick the single covering schedule (if any) so the row + // can render a one-click Run-now where it's unambiguous. Two store + // calls per host — fine at fleet sizes we care about. + rows := make([]dashboardHostRow, 0, len(hosts)) + for _, h := range hosts { + row := dashboardHostRow{Host: h} + groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID) + if gerr != nil { + slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr) + } + scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID) + if serr != nil { + slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr) + } + row.RunAllScheduleID = pickRunAllSchedule(scheds, groups) + rows = append(rows, row) + } + view := s.baseView(u, "dashboard") view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ - Hosts: hosts, + Hosts: rows, HostCount: len(hosts), Summary: summary, } diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index d1d128e..98b27ea 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -1,58 +1,66 @@ {{define "host_row"}} -
- {{.Name}} +{{$h := .Host}} +
+ {{$h.Name}}
- {{- if eq .Status "online" -}} - - {{- else if eq .Status "degraded" -}} + {{- if eq $h.Status "online" -}} + + {{- else if eq $h.Status "degraded" -}} - {{- else if eq .Status "offline" -}} + {{- else if eq $h.Status "offline" -}} {{- else -}} {{- end -}}
-
{{.Name}}
-
{{.OS}}/{{.Arch}}
+
{{$h.Name}}
+
{{$h.OS}}/{{$h.Arch}}
- {{- if .CurrentJobID -}} + {{- if $h.CurrentJobID -}} backup running…
- started {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "succeeded" -}} - succeeded · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "failed" -}} - failed · {{relTime .LastBackupAt}} - {{- else if eq (deref .LastBackupStatus) "cancelled" -}} - cancelled · {{relTime .LastBackupAt}} - {{- else if eq .Status "offline" -}} - last seen {{relTime .LastSeenAt}} + started {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "succeeded" -}} + succeeded · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "failed" -}} + failed · {{relTime $h.LastBackupAt}} + {{- else if eq (deref $h.LastBackupStatus) "cancelled" -}} + cancelled · {{relTime $h.LastBackupAt}} + {{- else if eq $h.Status "offline" -}} + last seen {{relTime $h.LastSeenAt}} {{- else -}} never run {{- end -}}
-
{{bytes .RepoSizeBytes}}
-
- {{- if eq .SnapshotCount 0 -}} +
{{bytes $h.RepoSizeBytes}}
+
+ {{- if eq $h.SnapshotCount 0 -}} {{- else -}} - {{comma .SnapshotCount}} + {{comma $h.SnapshotCount}} {{- end -}}
-
- {{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}} +
+ {{- if eq $h.OpenAlertCount 0 -}}—{{- else -}}{{$h.OpenAlertCount}}{{- end -}}
- {{- range .Tags -}} + {{- range $h.Tags -}} {{.}} {{- end -}}
- {{- if eq .Status "offline" -}} + {{- if eq $h.Status "offline" -}} offline - {{- else if .CurrentJobID -}} - View job → + {{- else if $h.CurrentJobID -}} + View job → + {{- else if .RunAllScheduleID -}} + {{- else -}} - Open → + Open → {{- end -}}
-- 2.52.0 From 2a8dd1eba2fb987cc373382c2dfaa58b0373f976 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 14:49:40 +0100 Subject: [PATCH 11/16] =?UTF-8?q?P2R-02=20=E2=9C=85=20=E2=80=94=20mark=20P?= =?UTF-8?q?hase=204=20complete,=20all=206=20slices=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update tasks.md: Phase 4 of the P2 redesign is done end-to-end. Slice 1–5 wired the four host-detail tabs against the new slim-schedule + source-group + repo-maintenance model; slice 6 ran a Playwright sweep against the live :8080 server (login, walk every tab, create source group, create schedule, Run-now, confirm a snapshot landed) — clean pass, no console errors. Screenshots in _diag/p2r-02-sweep/. Side-fix landed alongside slice 6: agent runner now drops restic's noisy --json status events from log.stream (the throttled job.progress envelope already covers them). Phase 5 (server-side maintenance ticker — P2R-03..08) is next. --- tasks.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tasks.md b/tasks.md index 06e6a3b..df03958 100644 --- a/tasks.md +++ b/tasks.md @@ -142,7 +142,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - **Auto-init at enrolment**: server dispatches `restic init` on first WS connect (was P2-old "Init repo" button — now invisible to the operator). On success: emit a normal job row with `kind=init` so the audit trail still shows it. On `init` returning "config file already exists" (e.g. re-enrolment against an existing repo): treat as soft success per existing restic-wrapper behaviour. - **Tests**: rewrite the deleted `schedules_test.go` and `schedule_push_test.go` against new endpoints; new `source_groups_test.go`, `repo_maintenance_test.go`, `auto_init_test.go`. End-to-end: enrol → server pushes creds → server dispatches init → agent runs it → schedule reconcile fires → operator hits per-source-group Run-now → backup runs → snapshots refresh. -### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) — IN PROGRESS +### P2 redesign — Phase 4 (UI rewire, against v4 wireframes) ✅ > **Row-design rule (binding for every list-row template in this app, current and future):** > Whole-row click navigates to the row's primary detail/edit page — @@ -155,13 +155,14 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. > cells are reserved for verbs that aren't "open this row" (Run-now, > Delete, Pause, etc). -- [ ] **P2R-02** (L) UI templates rebuilt against the new model: +- [x] **P2R-02** (L) UI templates rebuilt against the new model: - **Slice 1 ✅** Sub-tab navigation skeleton — extract header/vitals/sub-tabs into a `host_chrome` partial; Sources / Schedules / Repo become real `` links; placeholder pages share the chrome; version indicator restored. (commit `a535822`) - **Slice 2 ✅** Sources tab — `/hosts/{id}/sources` list with per-row meta + clickable rows + per-group Run-now/Delete; `/sources/new` and `/sources/{gid}/edit` form (name, includes/excludes, 3×2 keep-* grid, retry-on-offline, inline conflict banner from `ConflictDimension` cache); validation re-renders form with input intact; refuses to delete a host's last source group. (commits `0ed9c3d`, `dede74f`) - - **Slice 3 ✅** Schedules tab — `/hosts/{id}/schedules` slim list (status / cron / source-tags / actions, clickable rows) plus `/schedules/new` and `/schedules/{sid}/edit` form (cron with five quick-pick chips that have human-readable tooltips, source-group multi-pick as styled check cards, enabled toggle); per-schedule Run-now reuses `dispatchScheduledJob`. (commit `67ca769` + clickable-row follow-up) - - **Slice 4 (next)** `/hosts/{id}/repo` — connection (URL/user/password — pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; cert pin), bandwidth caps (host-wide; new `PUT /api/hosts/{id}/bandwidth`), maintenance rows (forget/prune/check cadence + check subset %), danger-zone re-init. - - **Slice 5** Per-source-group Run-now wiring on dashboard row + empty-snapshots state. Dashboard row's Run-now becomes either "Run all groups" (if exactly one schedule covers all groups) or "Open →" (multi-group hosts). - - **Slice 6** Playwright sweep — login → walk new tabs → create source group → create schedule → run-now → confirm dispatch. + - **Slice 3 ✅** Schedules tab — `/hosts/{id}/schedules` slim list (status / cron / source-tags / actions, clickable rows) plus `/schedules/new` and `/schedules/{sid}/edit` form (cron with five quick-pick chips that have human-readable tooltips, source-group multi-pick as styled check cards, enabled toggle); per-schedule Run-now reuses `dispatchScheduledJob` for enabled schedules + bypasses the enabled check (with a HX-confirm) for paused ones; multi-group fires emit a success toast, single-group fires HX-Redirect to the live job log. (commit `67ca769` + follow-ups `64d2fcf`, `8b91d30`, `4035c44`) + - **Slice 4 ✅** `/hosts/{id}/repo` — three independent forms (connection: URL/user/password pre-filled from `GET /api/hosts/{id}/repo-credentials` redacted view; bandwidth: host-wide caps via new `PUT /api/hosts/{id}/bandwidth`; maintenance: forget/prune/check cadences + check subset %); danger-zone re-init button rendered + disabled (real flow lands in P2R-09); right-rail snapshots-by-tag breakdown. (commit `d62b173`) + - **Slice 5 ✅** Dashboard row Run-now uses the single covering schedule when one exists ("Run all groups" primary button), otherwise falls back to "Open →" pointing at the Sources tab. Right-rail and empty-snapshots-state Run-now were rehomed to source-group context in slice 1. (commit `fab99b4`) + - **Slice 6 ✅** Playwright sweep against the live `:8080` server — login → walk every new tab → create source group → create schedule → Run-now → confirm a snapshot landed → end-to-end clean, no console errors. Screenshots in `_diag/p2r-02-sweep/`. + - Side-fix: agent runner drops noisy restic `status` events from `log.stream` (they were drowning the live log on short backups; the throttled `job.progress` envelope already covers the same data). (commit `ffba737`) - Header "version N · agent in sync / agent at vM" indicator preserved across all tabs (backed by `host_schedule_version` + `applied_schedule_version`). - Form validation re-renders with the operator's typed input intact (mirror P2-04's behaviour). Each save fires `pushScheduleSetAsync` so an online agent re-arms within seconds. -- 2.52.0 From 18a9f6624e8f4fa38b27f408f1f989aa16552fae Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 15:00:24 +0100 Subject: [PATCH 12/16] ci: migrate .golangci.yml to v2 schema + only-new-issues gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bump from golangci-lint-action@v6 → v7 (which downloads the v2.x binary) was blocking CI lint with 'unsupported version of the configuration: ""' because .golangci.yml was still in the v1 schema. Migrate the config to v2: * version: "2" prelude * disable-all → default: none * linters-settings → linters.settings * gofumpt + goimports move into formatters.enable + formatters.settings * exclude-rules move into linters.exclusions.rules * gosimple drops (folded into staticcheck in v2) Fix the four lint hits in the new P2R-02 code: * host_bandwidth.go: convert hostBandwidthRequest directly to hostBandwidthView via type conversion (S1016) * ui_repo.go: drop unparam savedSection + status arguments from renderRepoPage (always "" / always 422 — split GET render from validation-fail render) * ui_schedules.go: gofumpt formatting on the scheduleEditPage struct Add only-new-issues: true to the lint job. The repo carries ~90 pre-existing findings (gofumpt drift × 31, misspell × 25, missing godoc × 10, bodyclose × 6, errcheck × 12, …) accumulated before lint was actually wired into CI. Without this gate, every PR would fail on baseline noise instead of its own changes. Track the cleanup as X-06 in tasks.md so the gate is temporary. --- .gitea/workflows/ci.yml | 7 +++++ .golangci.yml | 42 +++++++++++++++----------- internal/server/http/host_bandwidth.go | 5 +-- internal/server/http/ui_repo.go | 34 ++++++++++----------- internal/server/http/ui_schedules.go | 14 ++++----- tasks.md | 1 + 6 files changed, 56 insertions(+), 47 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 091b753..a3c90fd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,6 +41,13 @@ jobs: # Bumping to a v2.x release built against current Go. version: v2.1.6 args: --timeout=5m + # Only flag issues introduced by the PR. The repo carries + # ~90 pre-existing findings (mostly missing godoc comments + # + gofumpt drift + misspell) accumulated before lint was + # actually wired into CI; cleaning them up is its own piece + # of work tracked separately. Without this, every PR fails + # on baseline noise instead of its own changes. + only-new-issues: true build: name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) diff --git a/.golangci.yml b/.golangci.yml index 45a99f6..787123f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,18 +1,17 @@ +version: "2" + run: timeout: 5m tests: true linters: - disable-all: true + default: none enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - unused - - gofumpt - - goimports - misspell - revive - bodyclose @@ -21,22 +20,29 @@ linters: - prealloc - unconvert - unparam - -linters-settings: - goimports: - local-prefixes: gitea.dcglab.co.uk/steve/restic-manager - revive: + settings: + revive: + rules: + - name: exported + arguments: ["disableStutteringCheck"] + misspell: + locale: US + exclusions: rules: - - name: exported - arguments: ["disableStutteringCheck"] - misspell: - locale: US + - path: _test\.go + linters: + - errcheck + - unparam + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - gitea.dcglab.co.uk/steve/restic-manager issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck - - unparam max-issues-per-linter: 0 max-same-issues: 0 diff --git a/internal/server/http/host_bandwidth.go b/internal/server/http/host_bandwidth.go index 6b3172c..e42996b 100644 --- a/internal/server/http/host_bandwidth.go +++ b/internal/server/http/host_bandwidth.go @@ -58,8 +58,5 @@ func (s *Server) handleUpdateHostBandwidth(w stdhttp.ResponseWriter, r *stdhttp. writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } - writeJSON(w, stdhttp.StatusOK, hostBandwidthView{ - BandwidthUpKBps: req.BandwidthUpKBps, - BandwidthDownKBps: req.BandwidthDownKBps, - }) + writeJSON(w, stdhttp.StatusOK, hostBandwidthView(req)) } diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index b9e334e..e0d54d6 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -153,25 +153,23 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) } // renderRepoFormError loads the page state, overlays the section's -// error / saved marker, and renders. Returns an HTTP status (422 for -// validation, 200 for success). -func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, savedSection, credErr, bwErr, mntErr string, status int) { +// error banner, and renders with a 422. Save-success goes through a +// 303 redirect with `?saved=
` instead, so this path is for +// validation failures only. +func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, bwErr, mntErr string) { page, err := s.loadHostRepoPage(r, *host) if err != nil { slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - page.SavedSection = savedSection page.CredentialsError = credErr page.BandwidthError = bwErr page.MaintenanceError = mntErr view := s.baseView(u, "dashboard") view.Title = host.Name + " repo · restic-manager" view.Page = *page - if status != stdhttp.StatusOK { - w.WriteHeader(status) - } + w.WriteHeader(stdhttp.StatusUnprocessableEntity) if err := s.deps.UI.Render(w, "host_repo", view); err != nil { slog.Error("ui: render host_repo", "err", err) } @@ -200,7 +198,7 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately if repoURL == "" { - s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "", stdhttp.StatusUnprocessableEntity) + s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "") return } @@ -217,9 +215,9 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt existing.RepoPassword = repoPass } if existing.RepoPassword == "" { - s.renderRepoPage(w, r, u, host, "", + s.renderRepoPage(w, r, u, host, "No password on file yet — set one before saving the URL/username.", - "", "", stdhttp.StatusUnprocessableEntity) + "", "") return } @@ -258,9 +256,9 @@ func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp. up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up")) down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down")) if upErr != nil || downErr != nil { - s.renderRepoPage(w, r, u, host, "", "", + s.renderRepoPage(w, r, u, host, "", "Bandwidth caps must be non-negative whole numbers (or blank for no cap).", - "", stdhttp.StatusUnprocessableEntity) + "") return } if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil { @@ -296,20 +294,20 @@ func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhtt "forget": forgetCron, "prune": pruneCron, "check": checkCron, } { if expr == "" { - s.renderRepoPage(w, r, u, host, "", "", "", - label+" cadence is required.", stdhttp.StatusUnprocessableEntity) + s.renderRepoPage(w, r, u, host, "", "", + label+" cadence is required.") return } if _, err := cronParser.Parse(expr); err != nil { - s.renderRepoPage(w, r, u, host, "", "", "", - label+" cadence didn't parse: "+err.Error(), stdhttp.StatusUnprocessableEntity) + s.renderRepoPage(w, r, u, host, "", "", + label+" cadence didn't parse: "+err.Error()) return } } subset, err := strconv.Atoi(subsetStr) if err != nil || subset < 0 || subset > 100 { - s.renderRepoPage(w, r, u, host, "", "", "", - "check subset % must be between 0 and 100.", stdhttp.StatusUnprocessableEntity) + s.renderRepoPage(w, r, u, host, "", "", + "check subset % must be between 0 and 100.") return } diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index 61f9e64..d17ff0d 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -39,13 +39,13 @@ type scheduleFormData struct { // scheduleEditPage backs both the new and edit form views. type scheduleEditPage struct { hostChromeData - IsNew bool - ScheduleID string // empty when IsNew - Form scheduleFormData - AvailableGroups []store.SourceGroup - SelectedGroupIDs map[string]bool // gid → checked - SaveAction string - Error string + IsNew bool + ScheduleID string // empty when IsNew + Form scheduleFormData + AvailableGroups []store.SourceGroup + SelectedGroupIDs map[string]bool // gid → checked + SaveAction string + Error string } func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) { diff --git a/tasks.md b/tasks.md index df03958..632454d 100644 --- a/tasks.md +++ b/tasks.md @@ -273,3 +273,4 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **X-03** Periodic dependency updates (`dependabot` or `renovate`) - [ ] **X-04** Threat-model review at end of each phase - [ ] **X-05** Proper first-run onboarding UI: admin shouldn't need to `curl` `/api/bootstrap` by hand. Render the bootstrap form on the same login page (extra "setup token" field shown only while no admin user exists, hidden after); on submit POST to `/api/bootstrap`, then drop straight into a session. Surface the one-time token from the server log somewhere copy-able (or print a clickable URL with the token in the query string at first-run). Also: relax the 12-char password floor for the first-run path or document it in the form so `admin` doesn't silently fail validation. +- [ ] **X-06** Lint-baseline cleanup pass. `.golangci.yml` is now on the v2 schema; CI is gated with `only-new-issues: true` because the repo carries ~90 pre-existing findings (gofumpt drift × 31, misspell × 25, missing godoc on exported consts × 10, bodyclose × 6, errcheck × 12, errorlint/nilerr/unused × handful) accumulated before lint was actually wired into CI. Drive the count to zero in a dedicated PR (mostly mechanical: `gofumpt -w .`, fix typos, add comments, audit nilerr cases since those *might* be real bugs), then drop `only-new-issues: true` so future regressions are caught at the source. -- 2.52.0 From e871b05b38343052865f24382a95be9b619c495a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 16:15:17 +0100 Subject: [PATCH 13/16] lint: drive baseline to zero, drop only-new-issues gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the repo so CI can enforce lint going forward without the only-new-issues escape hatch: * gofumpt -w across the tree (31 hits, all formatting) * misspell --fix (25 hits, US-locale spelling) — but reverted on api.JobCancelled = "cancelled" since that literal is the wire + DB CHECK constraint value, plus matched the case in store/fleet.go back to "cancelled" and added //nolint:misspell on both for the next time someone reaches for the auto-fix * Wrap every `defer rows.Close()` / `defer stmt.Close()` / `defer res.Body.Close()` in `defer func() { _ = .Close() }()` to satisfy errcheck without losing the close itself * websocket.Dial callers (1 prod, 4 tests) now capture + close the upgrade response Body — coder/websocket can return res with a nil Body on success, so the test deferred-closes guard against that * Annotate the two genuine-by-design nilerr cases with //nolint comments explaining why nil-on-error is the contract (cookie missing = no session; ctx cancelled mid-backoff = clean shutdown) * Add brief godoc on the 10 exported const groups + types that revive flagged (api.HostOS/HostArch/JobKind/JobStatus/LogStream/ ErrorCode, restic.EventKind, store.Role, web.FS) * Drop the unused (*Server).userByID method * Inline the unparam baseView(active) — every UI page is under the dashboard primary nav today Result: `golangci-lint run ./...` reports 0 issues. CI lint job no longer needs only-new-issues: true; X-06 follow-up entry in tasks.md removed. --- .gitea/workflows/ci.yml | 7 --- internal/agent/runner/runner.go | 14 ++--- internal/agent/scheduler/scheduler.go | 3 +- internal/agent/secrets/secrets.go | 2 +- internal/agent/sysinfo/sysinfo.go | 6 ++- internal/agent/wsclient/client.go | 15 ++++-- internal/agent/wsclient/enroll.go | 2 +- internal/api/messages.go | 42 ++++++++++----- internal/api/wire.go | 42 +++++++-------- internal/auth/passwords.go | 7 ++- internal/auth/passwords_test.go | 2 +- internal/crypto/aead.go | 2 +- internal/restic/runner.go | 26 +++++----- internal/restic/url_test.go | 6 ++- internal/server/config/config.go | 6 +-- internal/server/http/agent_assets.go | 2 +- internal/server/http/auth.go | 2 +- internal/server/http/auth_test.go | 6 ++- internal/server/http/enrollment.go | 12 ++--- internal/server/http/host_credentials.go | 2 +- internal/server/http/hosts.go | 34 ++++++------- internal/server/http/jobs.go | 4 +- internal/server/http/p2r01_test.go | 36 ++++++++----- internal/server/http/p2r01_ws_test.go | 13 +++-- internal/server/http/repo_maintenance.go | 1 - internal/server/http/schedule_push.go | 2 +- internal/server/http/schedules.go | 2 +- internal/server/http/server.go | 2 +- internal/server/http/ui_handlers.go | 65 ++++++++++-------------- internal/server/http/ui_repo.go | 4 +- internal/server/http/ui_schedules.go | 8 +-- internal/server/http/ui_sources.go | 8 +-- internal/server/ui/funcs.go | 8 +-- internal/server/ws/handler.go | 18 +++---- internal/server/ws/hub.go | 2 +- internal/server/ws/hub_test.go | 21 +++++++- internal/server/ws/jobhub.go | 4 +- internal/store/audit.go | 2 +- internal/store/enrollment.go | 1 - internal/store/fleet.go | 4 +- internal/store/hosts.go | 12 ++--- internal/store/jobs.go | 20 ++++---- internal/store/maintenance.go | 2 +- internal/store/migrate.go | 6 +-- internal/store/pending.go | 2 +- internal/store/schedules.go | 6 +-- internal/store/snapshots.go | 4 +- internal/store/snapshots_test.go | 28 +++++----- internal/store/sources.go | 10 ++-- internal/store/sources_test.go | 2 +- internal/store/store_test.go | 6 ++- internal/store/types.go | 29 ++++++----- tasks.md | 1 - web/embed.go | 4 ++ 54 files changed, 317 insertions(+), 260 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a3c90fd..091b753 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,13 +41,6 @@ jobs: # Bumping to a v2.x release built against current Go. version: v2.1.6 args: --timeout=5m - # Only flag issues introduced by the PR. The repo carries - # ~90 pre-existing findings (mostly missing godoc comments - # + gofumpt drift + misspell) accumulated before lint was - # actually wired into CI; cleaning them up is its own piece - # of work tracked separately. Without this, every PR fails - # on baseline noise instead of its own changes. - only-new-issues: true build: name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index e7b1e0b..dc6763c 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -99,13 +99,13 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t } lastProgress = time.Now() progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{ - JobID: jobID, - PercentDone: status.PercentDone, - FilesDone: status.FilesDone, - TotalFiles: status.TotalFiles, - BytesDone: status.BytesDone, - TotalBytes: status.TotalBytes, - ETASeconds: status.SecondsRem, + JobID: jobID, + PercentDone: status.PercentDone, + FilesDone: status.FilesDone, + TotalFiles: status.TotalFiles, + BytesDone: status.BytesDone, + TotalBytes: status.TotalBytes, + ETASeconds: status.SecondsRem, ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed), }) _ = r.tx.Send(progEnv) diff --git a/internal/agent/scheduler/scheduler.go b/internal/agent/scheduler/scheduler.go index c3ede1f..e9576ba 100644 --- a/internal/agent/scheduler/scheduler.go +++ b/internal/agent/scheduler/scheduler.go @@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) { "received", len(payload.Schedules), "active", added) // Ack outside the lock — Send() shouldn't take long, but holding - // s.mu across an external call would needlessly serialise other + // s.mu across an external call would needlessly serialize other // callers (e.g. a future Status() inspection from the UI). ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{ Version: payload.Version, @@ -167,4 +167,3 @@ func (s *Scheduler) fire(entry api.Schedule) { "schedule_id", entry.ID, "err", err) } } - diff --git a/internal/agent/secrets/secrets.go b/internal/agent/secrets/secrets.go index 8aa467f..f5bbc82 100644 --- a/internal/agent/secrets/secrets.go +++ b/internal/agent/secrets/secrets.go @@ -20,7 +20,7 @@ import ( // additionalData binds ciphertexts to the agent-secrets context, so a // blob lifted from one role's file can't be replayed into another's -// row in some unrelated table that uses the same key. (Defence in +// row in some unrelated table that uses the same key. (Defense in // depth — the key is per-host today, but cheap to be careful.) const additionalData = "rm-agent-repo-creds-v1" diff --git a/internal/agent/sysinfo/sysinfo.go b/internal/agent/sysinfo/sysinfo.go index c4b9c62..e0c369e 100644 --- a/internal/agent/sysinfo/sysinfo.go +++ b/internal/agent/sysinfo/sysinfo.go @@ -48,7 +48,9 @@ func Collect(ctx context.Context, resticPath string) (Snapshot, error) { // detectResticVersion runs `restic version` and parses the first line. // Output looks like: -// restic 0.17.1 compiled with go1.22.5 on linux/amd64 +// +// restic 0.17.1 compiled with go1.22.5 on linux/amd64 +// // Returns the version token (e.g. "0.17.1") or "" if restic isn't // found. We never block startup on a missing restic — the operator // might not have installed it yet, and the agent should still be @@ -74,5 +76,5 @@ func detectResticVersion(ctx context.Context, override string) (string, error) { if len(parts) >= 2 && parts[0] == "restic" { return parts[1], nil } - return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first) + return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first) } diff --git a/internal/agent/wsclient/client.go b/internal/agent/wsclient/client.go index f37f3e4..4e5d0b0 100644 --- a/internal/agent/wsclient/client.go +++ b/internal/agent/wsclient/client.go @@ -40,7 +40,7 @@ type Config struct { // Sender is what handlers use to push agent → server messages // (job.progress, job.finished, log.stream, command.result, …). // Returned by the WS client to the dispatch handler. Write operations -// serialise behind a single mutex on the conn; concurrent calls are +// serialize behind a single mutex on the conn; concurrent calls are // safe. type Sender interface { Send(env api.Envelope) error @@ -52,7 +52,7 @@ type Sender interface { type Handler func(ctx context.Context, env api.Envelope, tx Sender) error // Run keeps the agent connected indefinitely. Returns when ctx is -// cancelled. Errors during a single connection attempt are logged and +// canceled. Errors during a single connection attempt are logged and // trigger reconnect-with-backoff; only ctx.Done() ends the loop. func Run(ctx context.Context, cfg Config, handle Handler) error { if cfg.HeartbeatPeriod <= 0 { @@ -69,7 +69,10 @@ func Run(ctx context.Context, cfg Config, handle Handler) error { slog.Warn("ws agent disconnect", "err", err) } if err := sleepCtx(ctx, backoff.next()); err != nil { - return nil + // ctx cancellation mid-backoff means the parent shut us down — + // exit the reconnect loop quietly rather than propagating + // a context error up to a caller that will discard it. + return nil //nolint:nilerr } } } @@ -100,11 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error { } dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) + conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts) cancel() if err != nil { return fmt.Errorf("dial: %w", err) } + // websocket.Dial returns the upgrade response separately from the + // conn. Body is empty on a successful upgrade but Go's net/http + // still expects it closed to release the connection. + defer func() { _ = res.Body.Close() }() defer conn.CloseNow() //nolint:errcheck // Send hello. diff --git a/internal/agent/wsclient/enroll.go b/internal/agent/wsclient/enroll.go index 6ba00bd..5fee682 100644 --- a/internal/agent/wsclient/enroll.go +++ b/internal/agent/wsclient/enroll.go @@ -50,7 +50,7 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe if err != nil { return nil, fmt.Errorf("agent enroll: post: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() rawRes, _ := io.ReadAll(res.Body) if res.StatusCode != stdhttp.StatusCreated { return nil, fmt.Errorf("agent enroll: server returned %d: %s", diff --git a/internal/api/messages.go b/internal/api/messages.go index ad98a7e..c93cad6 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -10,13 +10,18 @@ import ( // constants so we don't end up with both "linux" and "Linux" rows. type HostOS string +// Allowed values for HostOS. Lowercased on the wire so the server +// can use a single CHECK constraint. const ( OSLinux HostOS = "linux" OSWindows HostOS = "windows" ) +// HostArch is the agent's CPU architecture; same lowercase-on-wire +// rule as HostOS. type HostArch string +// Allowed values for HostArch. const ( ArchAmd64 HostArch = "amd64" ArchArm64 HostArch = "arm64" @@ -45,6 +50,9 @@ type HeartbeatPayload struct { // JobKind is the operation an agent is being asked to run, or just ran. type JobKind string +// Allowed JobKind values. backup is operator/cron driven; init runs +// once per host on first connect; forget/prune/check fire from the +// server-side maintenance ticker; unlock is operator-only. const ( JobBackup JobKind = "backup" JobInit JobKind = "init" @@ -57,12 +65,16 @@ const ( // JobStatus is the lifecycle state of a job. type JobStatus string +// Allowed JobStatus values. queued → running → one of {succeeded, +// failed, JobCancelled} as a terminal state. The wire/DB literal for +// the JobCancelled value uses UK spelling — don't "fix" it; existing +// job rows + agent payloads will mismatch. //nolint:misspell const ( JobQueued JobStatus = "queued" JobRunning JobStatus = "running" JobSucceeded JobStatus = "succeeded" JobFailed JobStatus = "failed" - JobCancelled JobStatus = "cancelled" + JobCancelled JobStatus = "cancelled" //nolint:misspell // wire format ) // CommandRunPayload is the server → agent dispatch for a run-now job. @@ -145,6 +157,8 @@ type LogStreamLine struct { // LogStream identifies which channel a log line came from. type LogStream string +// Allowed LogStream values. stdout/stderr are passed through verbatim; +// event is the parsed restic --json envelope (summary, error, etc). const ( LogStdout LogStream = "stdout" LogStderr LogStream = "stderr" @@ -175,12 +189,12 @@ type Snapshot struct { // RepoStatsPayload — agent reports periodic repo health facts derived // from `restic stats` and lock-file inspection. type RepoStatsPayload struct { - SizeBytes int64 `json:"size_bytes"` - SnapshotCount int `json:"snapshot_count"` - DedupRatio float64 `json:"dedup_ratio"` - LastCheckAt time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LockState string `json:"lock_state"` // locked|unlocked + SizeBytes int64 `json:"size_bytes"` + SnapshotCount int `json:"snapshot_count"` + DedupRatio float64 `json:"dedup_ratio"` + LastCheckAt time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LockState string `json:"lock_state"` // locked|unlocked } // Schedule is the agent-facing view of a slim Schedule row plus its @@ -220,8 +234,8 @@ type ScheduleSetPayload struct { // ScheduleAckPayload — agent confirms it has applied a given version. type ScheduleAckPayload struct { - Version int64 `json:"version"` - AppliedAt time.Time `json:"applied_at"` + Version int64 `json:"version"` + AppliedAt time.Time `json:"applied_at"` } // ScheduleFirePayload — agent reports a local cron entry just fired. @@ -239,11 +253,11 @@ type ScheduleFirePayload struct { // repo connection details). Empty fields mean "leave existing alone"; // to clear something, send an explicit zero value. type ConfigUpdatePayload struct { - RepoURL string `json:"repo_url,omitempty"` - RepoPassword string `json:"repo_password,omitempty"` // sensitive - RepoUsername string `json:"repo_username,omitempty"` - RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) - HookShell string `json:"hook_shell,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + RepoPassword string `json:"repo_password,omitempty"` // sensitive + RepoUsername string `json:"repo_username,omitempty"` + RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) + HookShell string `json:"hook_shell,omitempty"` } // AgentUpdateAvailablePayload — informational only; the agent does diff --git a/internal/api/wire.go b/internal/api/wire.go index d551bb0..df646a5 100644 --- a/internal/api/wire.go +++ b/internal/api/wire.go @@ -12,35 +12,35 @@ type MessageType string // Agent → server message types. const ( - MsgHello MessageType = "hello" - MsgHeartbeat MessageType = "heartbeat" - MsgJobStarted MessageType = "job.started" - MsgJobProgress MessageType = "job.progress" - MsgJobFinished MessageType = "job.finished" - MsgSnapshotsRpt MessageType = "snapshots.report" - MsgRepoStats MessageType = "repo.stats" - MsgLogStream MessageType = "log.stream" - MsgScheduleAck MessageType = "schedule.ack" - MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job - MsgCommandResult MessageType = "command.result" // ack for command.run - MsgError MessageType = "error" + MsgHello MessageType = "hello" + MsgHeartbeat MessageType = "heartbeat" + MsgJobStarted MessageType = "job.started" + MsgJobProgress MessageType = "job.progress" + MsgJobFinished MessageType = "job.finished" + MsgSnapshotsRpt MessageType = "snapshots.report" + MsgRepoStats MessageType = "repo.stats" + MsgLogStream MessageType = "log.stream" + MsgScheduleAck MessageType = "schedule.ack" + MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job + MsgCommandResult MessageType = "command.result" // ack for command.run + MsgError MessageType = "error" ) // Server → agent message types. const ( - MsgCommandRun MessageType = "command.run" - MsgCommandCancel MessageType = "command.cancel" - MsgScheduleSet MessageType = "schedule.set" - MsgConfigUpdate MessageType = "config.update" - MsgAgentUpdateAvail MessageType = "agent.update.available" + MsgCommandRun MessageType = "command.run" + MsgCommandCancel MessageType = "command.cancel" + MsgScheduleSet MessageType = "schedule.set" + MsgConfigUpdate MessageType = "config.update" + MsgAgentUpdateAvail MessageType = "agent.update.available" ) // Envelope is the framing for every WS message in either direction. // Payload is parsed into the concrete struct chosen by Type. // -// ID is set on RPC-style messages (command.run / command.result) so -// responses can be correlated. For one-shot pushes (heartbeat, -// job.progress) it is empty. +// ID is set on RPC-style messages (command.run / command.result) so +// responses can be correlated. For one-shot pushes (heartbeat, +// job.progress) it is empty. type Envelope struct { Type MessageType `json:"type"` ID string `json:"id,omitempty"` @@ -71,6 +71,8 @@ func (e Envelope) UnmarshalPayload(v any) error { // These are stable identifiers; client code may switch on them. type ErrorCode string +// Stable ErrorCode values surfaced over the wire. Clients switch on +// these; renaming requires a wire-version bump. const ( ErrProtocolTooOld ErrorCode = "protocol_too_old" ErrProtocolTooNew ErrorCode = "protocol_too_new" diff --git a/internal/auth/passwords.go b/internal/auth/passwords.go index dd26567..d245ace 100644 --- a/internal/auth/passwords.go +++ b/internal/auth/passwords.go @@ -16,6 +16,7 @@ import ( // argon2id parameters following RFC 9106 §4 "second // recommended option" (memory-constrained): // - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag. +// // These are tunable per-deployment if a beefy controller wants to // crank them; we ship a defensible default. const ( @@ -27,7 +28,9 @@ const ( ) // HashPassword returns an argon2id-encoded string of the form -// $argon2id$v=19$m=...,t=...,p=...$$ +// +// $argon2id$v=19$m=...,t=...,p=...$$ +// // safe to store in a TEXT column. The salt is freshly random per call. func HashPassword(password string) (string, error) { salt := make([]byte, defaultSaltLen) @@ -53,7 +56,7 @@ func VerifyPassword(encoded, password string) error { parts := strings.Split(encoded, "$") // "$argon2id$v=...$m=...,t=...,p=...$$" → 6 parts (leading empty) if len(parts) != 6 || parts[1] != "argon2id" { - return errors.New("auth: unrecognised hash format") + return errors.New("auth: unrecognized hash format") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { diff --git a/internal/auth/passwords_test.go b/internal/auth/passwords_test.go index b7f82bd..8256289 100644 --- a/internal/auth/passwords_test.go +++ b/internal/auth/passwords_test.go @@ -41,7 +41,7 @@ func TestVerifyRejectsMalformed(t *testing.T) { "", "not-a-hash", "$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant - "$argon2id$", // truncated + "$argon2id$", // truncated "$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version } for _, c := range cases { diff --git a/internal/crypto/aead.go b/internal/crypto/aead.go index 4564d30..7a68264 100644 --- a/internal/crypto/aead.go +++ b/internal/crypto/aead.go @@ -65,7 +65,7 @@ func GenerateKeyFile(path string) error { if err != nil { return fmt.Errorf("create key file %q: %w", path, err) } - defer f.Close() + defer func() { _ = f.Close() }() key := make([]byte, KeyLen) if _, err := io.ReadFull(rand.Reader, key); err != nil { return fmt.Errorf("read random: %w", err) diff --git a/internal/restic/runner.go b/internal/restic/runner.go index c0d0d22..05721af 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -15,7 +15,7 @@ import ( "time" ) -// Locate resolves the path to the restic binary. Honour an explicit +// Locate resolves the path to the restic binary. Honor an explicit // override if provided, else fall back to PATH. func Locate(override string) (string, error) { if override != "" { @@ -41,12 +41,12 @@ func Locate(override string) (string, error) { // never assign it back to Env, never pass it to slog. If anything // in this package ever needs to *log* a URL, use RedactURL. type Env struct { - Bin string // path to restic binary - RepoURL string // RESTIC_REPOSITORY (no embedded creds) - RepoUsername string // optional HTTP basic-auth user for rest: URLs - RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password - ExtraEnv map[string]string // any other RESTIC_* / passthrough - WorkDir string // CWD; default = current + Bin string // path to restic binary + RepoURL string // RESTIC_REPOSITORY (no embedded creds) + RepoUsername string // optional HTTP basic-auth user for rest: URLs + RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password + ExtraEnv map[string]string // any other RESTIC_* / passthrough + WorkDir string // CWD; default = current } // EventKind enumerates what we care about in restic's --json output @@ -54,10 +54,12 @@ type Env struct { // switch on message_type. type EventKind string +// Known message_type values restic --json emits during a backup. +// Kept as constants so callers can switch without typo risk. const ( - EventStatus EventKind = "status" // periodic progress + EventStatus EventKind = "status" // periodic progress EventVerbose EventKind = "verbose_status" - EventSummary EventKind = "summary" // emitted once at end of backup + EventSummary EventKind = "summary" // emitted once at end of backup EventErrorEvent EventKind = "error" ) @@ -90,7 +92,7 @@ type BackupSummary struct { } // LineHandler receives every stdout/stderr line. event is non-nil -// when the line is a recognised JSON status; raw always carries the +// when the line is a recognized JSON status; raw always carries the // original text (so we can also tee to job_logs as `stdout`). type LineHandler func(stream string, raw string, event any) @@ -256,7 +258,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { // Sniff for "config file already exists" on stderr; if we see it // we'll treat the non-zero exit as a soft success — running init - // against an already-initialised repo is a no-op semantically, + // against an already-initialized repo is a no-op semantically, // not a failure. Wraps the caller's handle so the line still // gets streamed verbatim to the operator-facing log. alreadyInited := false @@ -280,7 +282,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { if werr := cmd.Wait(); werr != nil { if alreadyInited { if handle != nil { - handle("event", "repo already initialised — treating as success", nil) + handle("event", "repo already initialized — treating as success", nil) } return nil } diff --git a/internal/restic/url_test.go b/internal/restic/url_test.go index 63f9b5d..03c2920 100644 --- a/internal/restic/url_test.go +++ b/internal/restic/url_test.go @@ -8,9 +8,11 @@ func TestMergeRestCreds(t *testing.T) { }{ {"rest with creds", "rest:http://h:8000/p/", "u", "p", "rest:http://u:p@h:8000/p/"}, {"rest no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"}, - {"rest creds already inline — no-op", + { + "rest creds already inline — no-op", "rest:http://existing:secret@h:8000/p/", "u", "p", - "rest:http://existing:secret@h:8000/p/"}, + "rest:http://existing:secret@h:8000/p/", + }, {"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"}, {"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"}, {"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"}, diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 9e09ecd..0d883cf 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -34,9 +34,9 @@ type Config struct { } // Load resolves config in this order: -// 1. defaults -// 2. YAML at the given path (if non-empty and exists) -// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) +// 1. defaults +// 2. YAML at the given path (if non-empty and exists) +// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) // // The result is validated; a zero-error return means the server is // safe to start. diff --git a/internal/server/http/agent_assets.go b/internal/server/http/agent_assets.go index 2efbd8e..4808504 100644 --- a/internal/server/http/agent_assets.go +++ b/internal/server/http/agent_assets.go @@ -57,7 +57,7 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) } func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) { - // chi's TrimPrefix-like behaviour: r.URL.Path is "/install/". + // chi's TrimPrefix-like behavior: r.URL.Path is "/install/". rel := strings.TrimPrefix(r.URL.Path, "/install/") // Reject any path traversal — must be a flat filename. if rel == "" || strings.ContainsAny(rel, "/\\") { diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 6c0fc2e..cb25f71 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) { return } if n > 0 { - writeJSONError(w, stdhttp.StatusConflict, "already_initialised", + writeJSONError(w, stdhttp.StatusConflict, "already_initialized", "a user already exists; bootstrap is disabled") return } diff --git a/internal/server/http/auth_test.go b/internal/server/http/auth_test.go index 740eb21..18650dc 100644 --- a/internal/server/http/auth_test.go +++ b/internal/server/http/auth_test.go @@ -36,7 +36,7 @@ func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) { aead, _ := crypto.NewAEAD(key) deps := Deps{ - Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, Store: st, AEAD: aead, } @@ -125,7 +125,9 @@ func TestLoginAndLogout(t *testing.T) { bs, _ := json.Marshal(bootstrapRequest{ Token: "test-token", Username: "alice", Password: "averylongpassword", }) - stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)) //nolint:errcheck + if bsRes, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)); err == nil { + _ = bsRes.Body.Close() + } // Login. body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"}) diff --git a/internal/server/http/enrollment.go b/internal/server/http/enrollment.go index e0f3f26..2706ea5 100644 --- a/internal/server/http/enrollment.go +++ b/internal/server/http/enrollment.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "fmt" "log/slog" stdhttp "net/http" @@ -142,7 +143,7 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) // Seed the host's "default" source group with whatever paths the // operator typed into Add-host (empty allowed; group is editable - // from the Sources tab post-enrol). Also seed the host's + // from the Sources tab post-enroll). Also seed the host's // repo-maintenance row with default cadences so forget/prune/check // start ticking on their own. Auto-init dispatch lands in Phase 6 // of the redesign. @@ -222,12 +223,11 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt return } token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths) - switch err { - case nil: + switch { + case err == nil: writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt}) - case errMissingRepoCreds: - writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", - "repo_url and repo_password are required so the agent can run backups on first connect") + case errors.Is(err, errMissingRepoCreds): + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect") default: writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") } diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index 2c564c0..5887a75 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -162,7 +162,7 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R w.WriteHeader(stdhttp.StatusNoContent) } -// pushRepoCredsToAgent serialises blob into a config.update envelope +// pushRepoCredsToAgent serializes blob into a config.update envelope // and ships it down the agent's WS. Returns an error from the hub // (no-op if not connected — caller is expected to check first when it // matters). diff --git a/internal/server/http/hosts.go b/internal/server/http/hosts.go index d969e33..e1d5ca3 100644 --- a/internal/server/http/hosts.go +++ b/internal/server/http/hosts.go @@ -10,23 +10,23 @@ import ( // store row, but with explicit time-strings so wire format is stable // across DB driver changes. type hostView struct { - ID string `json:"id"` - Name string `json:"name"` - OS string `json:"os"` - Arch string `json:"arch"` - AgentVersion string `json:"agent_version,omitempty"` - ResticVersion string `json:"restic_version,omitempty"` - ProtocolVersion int `json:"protocol_version"` - EnrolledAt string `json:"enrolled_at"` - LastSeenAt *string `json:"last_seen_at,omitempty"` - Status string `json:"status"` - Tags []string `json:"tags"` - CurrentJobID *string `json:"current_job_id,omitempty"` - LastBackupAt *string `json:"last_backup_at,omitempty"` - LastBackupStatus *string `json:"last_backup_status,omitempty"` - RepoSizeBytes int64 `json:"repo_size_bytes"` - SnapshotCount int `json:"snapshot_count"` - OpenAlertCount int `json:"open_alert_count"` + ID string `json:"id"` + Name string `json:"name"` + OS string `json:"os"` + Arch string `json:"arch"` + AgentVersion string `json:"agent_version,omitempty"` + ResticVersion string `json:"restic_version,omitempty"` + ProtocolVersion int `json:"protocol_version"` + EnrolledAt string `json:"enrolled_at"` + LastSeenAt *string `json:"last_seen_at,omitempty"` + Status string `json:"status"` + Tags []string `json:"tags"` + CurrentJobID *string `json:"current_job_id,omitempty"` + LastBackupAt *string `json:"last_backup_at,omitempty"` + LastBackupStatus *string `json:"last_backup_status,omitempty"` + RepoSizeBytes int64 `json:"repo_size_bytes"` + SnapshotCount int `json:"snapshot_count"` + OpenAlertCount int `json:"open_alert_count"` } // handleListHosts returns the full fleet as JSON. Authenticated; the diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 7b90d10..e6afd7a 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -16,8 +16,8 @@ import ( // runNowRequest is the body of POST /api/hosts/:id/jobs. type runNowRequest struct { - Kind api.JobKind `json:"kind"` - Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) + Kind api.JobKind `json:"kind"` + Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) } type runNowResponse struct { diff --git a/internal/server/http/p2r01_test.go b/internal/server/http/p2r01_test.go index 7042e1f..6863e87 100644 --- a/internal/server/http/p2r01_test.go +++ b/internal/server/http/p2r01_test.go @@ -215,24 +215,30 @@ func TestSchedulesCRUDValidation(t *testing.T) { // Bad cron → 400. status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", - map[string]any{"cron": "not-a-cron", "enabled": true, - "source_group_ids": []string{"x"}}, cookie) + map[string]any{ + "cron": "not-a-cron", "enabled": true, + "source_group_ids": []string{"x"}, + }, cookie) if status != 400 { t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body) } // Missing groups → 400. status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", - map[string]any{"cron": "0 3 * * *", "enabled": true, - "source_group_ids": []string{}}, cookie) + map[string]any{ + "cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{}, + }, cookie) if status != 400 { t.Errorf("missing groups: want 400, got %d", status) } // Group not on host → 400. status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", - map[string]any{"cron": "0 3 * * *", "enabled": true, - "source_group_ids": []string{"non-existent"}}, cookie) + map[string]any{ + "cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{"non-existent"}, + }, cookie) if status != 400 { t.Errorf("bogus group: want 400, got %d", status) } @@ -247,8 +253,10 @@ func TestSchedulesCRUDValidation(t *testing.T) { // Happy create. status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", - map[string]any{"cron": "0 3 * * *", "enabled": true, - "source_group_ids": []string{gid}}, cookie) + map[string]any{ + "cron": "0 3 * * *", "enabled": true, + "source_group_ids": []string{gid}, + }, cookie) if status != 201 { t.Fatalf("create: %d body=%+v", status, body) } @@ -269,8 +277,10 @@ func TestSchedulesCRUDValidation(t *testing.T) { // Update — change cron, keep group. status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid, - map[string]any{"cron": "@hourly", "enabled": false, - "source_group_ids": []string{gid}}, cookie) + map[string]any{ + "cron": "@hourly", "enabled": false, + "source_group_ids": []string{gid}, + }, cookie) if status != 200 { t.Fatalf("update: %d body=%+v", status, body) } @@ -484,5 +494,7 @@ func equalStrings(a, b []string) bool { } // keep fmt import live — used for occasional debug. -var _ = fmt.Sprintf -var _ = strings.HasPrefix +var ( + _ = fmt.Sprintf + _ = strings.HasPrefix +) diff --git a/internal/server/http/p2r01_ws_test.go b/internal/server/http/p2r01_ws_test.go index 5555f9d..ed53847 100644 --- a/internal/server/http/p2r01_ws_test.go +++ b/internal/server/http/p2r01_ws_test.go @@ -6,8 +6,8 @@ package http import ( "context" "encoding/json" - "net/http/httptest" stdhttp "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -29,13 +29,18 @@ func agentDial(t *testing.T, srv *Server, ts *httptest.Server, hostID, token str url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent" ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } - t.Cleanup(func() { _ = c.CloseNow() }) + t.Cleanup(func() { + _ = c.CloseNow() + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }) return c } @@ -76,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E return api.Envelope{} } -// enrolHostForWS pre-enrols a host with bound repo creds so the server +// enrolHostForWS pre-enrolls a host with bound repo creds so the server // will treat it as ready to receive command.run. func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) { t.Helper() diff --git a/internal/server/http/repo_maintenance.go b/internal/server/http/repo_maintenance.go index e020b30..364024b 100644 --- a/internal/server/http/repo_maintenance.go +++ b/internal/server/http/repo_maintenance.go @@ -142,4 +142,3 @@ func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhtt } writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m)) } - diff --git a/internal/server/http/schedule_push.go b/internal/server/http/schedule_push.go index e82dfc6..02692b7 100644 --- a/internal/server/http/schedule_push.go +++ b/internal/server/http/schedule_push.go @@ -4,7 +4,7 @@ // The slim-schedule wire shape is built here from the (Schedule, // SourceGroup) pair. Each schedule is sent with its resolved source // groups inlined so the agent doesn't have to keep its own copy of -// the group catalogue. Cron + enabled drive the agent's local timer; +// the group catalog. Cron + enabled drive the agent's local timer; // when an entry fires the agent ships back a schedule.fire and // dispatchScheduledJob below resolves the schedule's groups and // dispatches one backup command.run per group. diff --git a/internal/server/http/schedules.go b/internal/server/http/schedules.go index 95c4e58..1e33e9d 100644 --- a/internal/server/http/schedules.go +++ b/internal/server/http/schedules.go @@ -212,7 +212,7 @@ func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req for _, gid := range req.SourceGroupIDs { g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid) if err != nil || g == nil { - return "invalid_group", "source group "+gid+" not found on this host", false + return "invalid_group", "source group " + gid + " not found on this host", false } } return "", "", true diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 5d09a09..f286fdb 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -184,7 +184,7 @@ func (s *Server) routes(r chi.Router) { // Durable post-Add-host page (operator can refresh / come // back; password decrypted from the token row each render). // Polled fragment under /awaiting flips to "connected" once - // the agent enrols. + // the agent enrolls. r.Get("/hosts/pending/{token}", s.handleUIPendingHost) r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) // Host detail (Snapshots tab is the default). diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 755e8fd..0dd4752 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -44,7 +44,11 @@ func staticHandler() stdhttp.Handler { func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { c, err := r.Cookie(sessionCookieName) if err != nil { - return nil, nil + // Missing or invalid cookie just means the caller isn't logged + // in — that's a normal state, not a server error. Return + // (nil, nil) so callers can decide between "redirect to login" + // and "treat as anonymous". + return nil, nil //nolint:nilerr } sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) if err != nil { @@ -81,11 +85,13 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui } // baseView populates the fields the nav partial needs on every -// authenticated page. -func (s *Server) baseView(u *ui.User, active string) ui.ViewData { +// authenticated page. Every UI page sits under the dashboard primary +// nav today; if a future page lives under a different primary nav +// tab (e.g. Settings, Audit), accept an Active arg again. +func (s *Server) baseView(u *ui.User) ui.ViewData { return ui.ViewData{ User: u, - Active: active, + Active: "dashboard", Version: s.version(), } } @@ -200,7 +206,7 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) rows = append(rows, row) } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ Hosts: rows, @@ -249,16 +255,16 @@ type addHostPage struct { } // pendingHostPage is the GET /hosts/pending/{token} view. Lives -// for as long as the token does (1h ttl); once the agent enrols, +// for as long as the token does (1h ttl); once the agent enrolls, // the handler redirects to /hosts/{host_id} and this page is gone. type pendingHostPage struct { - Token string - ServerURL string - ExpiresAt time.Time - RepoURL string - RepoUsername string - RepoPassword string - InitialPaths []string + Token string + ServerURL string + ExpiresAt time.Time + RepoURL string + RepoUsername string + RepoPassword string + InitialPaths []string } // handleUIAddHostGet renders the empty Add host form. @@ -267,7 +273,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request if u == nil { return } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Add host · restic-manager" view.Page = addHostPage{ServerURL: s.publicURL(r)} if err := s.deps.UI.Render(w, "add_host", view); err != nil { @@ -327,11 +333,11 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques if page.Error == "" { token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths)) - switch err { - case nil: + switch { + case err == nil: stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) return - case errMissingRepoCreds: + case errors.Is(err, errMissingRepoCreds): page.Error = "Repo URL and password are both required." default: slog.Error("ui add_host: mint token", "err", err) @@ -339,7 +345,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques } } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Add host · restic-manager" view.Page = page w.WriteHeader(stdhttp.StatusUnprocessableEntity) @@ -350,7 +356,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques // handleUIPendingHost serves the durable Add-host result page — // shown after a successful POST /hosts/new and reachable until the -// agent enrols (the page redirects to /hosts/{id} once that +// agent enrolls (the page redirects to /hosts/{id} once that // happens) or the token expires (1h ttl). The password is // re-decrypted from the encrypted token row on every render so // the operator can refresh, bookmark, navigate away and come back. @@ -406,7 +412,7 @@ func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Reques } } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Pending host · restic-manager" view.Page = page if err := s.deps.UI.Render(w, "pending_host", view); err != nil { @@ -546,7 +552,7 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request shown = shown[:cap] } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " · restic-manager" view.Page = hostDetailPage{ hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), @@ -645,7 +651,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) nextSeq = logs[n-1].Seq } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = job.Kind + " · " + host.Name + " · restic-manager" view.Page = jobDetailPage{ Job: *job, @@ -742,21 +748,6 @@ func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) { }) } -// 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 -// projected ui.User. -func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) { - u, err := s.deps.Store.GetUserByID(r.Context(), id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - return nil, false, nil - } - return nil, false, err - } - return u, true, nil -} - // handleUILoginGet renders the login form. If the user is already // signed in we redirect them home — login is for the unauthenticated. func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index e0d54d6..79ad2ae 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -143,7 +143,7 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) return } page.SavedSection = r.URL.Query().Get("saved") - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " repo · restic-manager" view.Page = *page if err := s.deps.UI.Render(w, "host_repo", view); err != nil { @@ -166,7 +166,7 @@ func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u page.CredentialsError = credErr page.BandwidthError = bwErr page.MaintenanceError = mntErr - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " repo · restic-manager" view.Page = *page w.WriteHeader(stdhttp.StatusUnprocessableEntity) diff --git a/internal/server/http/ui_schedules.go b/internal/server/http/ui_schedules.go index d17ff0d..485e64e 100644 --- a/internal/server/http/ui_schedules.go +++ b/internal/server/http/ui_schedules.go @@ -78,7 +78,7 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ chrome.ScheduleCount = len(scheds) chrome.SourceGroupCount = len(groups) - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " schedules · restic-manager" view.Page = hostSchedulesPage{ hostChromeData: chrome, @@ -106,7 +106,7 @@ func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Req stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "New schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"), @@ -152,7 +152,7 @@ func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Re for _, gid := range sc.SourceGroupIDs { selected[gid] = true } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Edit schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"), @@ -381,7 +381,7 @@ func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Re saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit" crumb = "edit schedule" } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Schedule · " + host.Name + " · restic-manager" view.Page = scheduleEditPage{ hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb), diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go index cdcd978..c8ed59f 100644 --- a/internal/server/http/ui_sources.go +++ b/internal/server/http/ui_sources.go @@ -119,7 +119,7 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques // loadHostChrome already counted groups; reuse count we just got. chrome.SourceGroupCount = len(groups) - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = host.Name + " sources · restic-manager" view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows} if err := s.deps.UI.Render(w, "host_sources", view); err != nil { @@ -137,7 +137,7 @@ func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp. if !ok { return } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "New source group · " + host.Name + " · restic-manager" view.Page = sourceGroupEditPage{ hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"), @@ -171,7 +171,7 @@ func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = g.Name + " · " + host.Name + " · restic-manager" view.Page = sourceGroupEditPage{ hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name), @@ -341,7 +341,7 @@ func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp. // typed input intact + an error banner. Returns 422 to signal "form // rejected" while still returning HTML (mirrors handleUIAddHostPost). func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) { - view := s.baseView(u, "dashboard") + view := s.baseView(u) view.Title = "Source group · " + host.Name + " · restic-manager" saveAction := "/hosts/" + host.ID + "/sources/new" crumb := "new source group" diff --git a/internal/server/ui/funcs.go b/internal/server/ui/funcs.go index e1bf86f..71c350a 100644 --- a/internal/server/ui/funcs.go +++ b/internal/server/ui/funcs.go @@ -13,10 +13,10 @@ import ( // which can pre-compute and pass primitives into the view. func funcMap() template.FuncMap { return template.FuncMap{ - "bytes": formatBytes, - "relTime": formatRelTime, - "comma": formatComma, - "deref": derefStr, + "bytes": formatBytes, + "relTime": formatRelTime, + "comma": formatComma, + "deref": derefStr, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, "joinDot": func(parts []string) string { return strings.Join(parts, " · ") }, "absTime": func(t time.Time) string { diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index 25db3e3..48fb5fd 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -42,14 +42,14 @@ type HandlerDeps struct { // enrollment) before the WS upgrade. // // Lifecycle: -// 1. Bearer token resolves to a Host row. -// 2. Upgrade. -// 3. First message must be `hello`; protocol_version checked here. -// 4. Loop: read messages, dispatch by type. Heartbeats touch the -// host row; job/log/repo messages forward to the relevant -// handlers (TODO: lands with P1-18 onward). -// 5. On Read error or context cancel, mark host offline, unregister -// from the hub. +// 1. Bearer token resolves to a Host row. +// 2. Upgrade. +// 3. First message must be `hello`; protocol_version checked here. +// 4. Loop: read messages, dispatch by type. Heartbeats touch the +// host row; job/log/repo messages forward to the relevant +// handlers (TODO: lands with P1-18 onward). +// 5. On Read error or context cancel, mark host offline, unregister +// from the hub. func AgentHandler(deps HandlerDeps) stdhttp.Handler { return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { host, ok := authenticateAgent(r, deps.Store) @@ -204,7 +204,7 @@ 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) } - // repo_initialised_at projection has been removed — auto-init + // repo_initialized_at projection has been removed — auto-init // at host enrolment makes "is the repo init'd" derivable from // the latest init job's status, no separate column needed. if deps.JobHub != nil { diff --git a/internal/server/ws/hub.go b/internal/server/ws/hub.go index c10a85d..8ad732f 100644 --- a/internal/server/ws/hub.go +++ b/internal/server/ws/hub.go @@ -100,7 +100,7 @@ func NewConn(hostID string, c *websocket.Conn) *Conn { } // Send writes an envelope as a JSON text message. Concurrent calls -// are serialised; the underlying socket is not safe for parallel +// are serialized; the underlying socket is not safe for parallel // writers. func (c *Conn) Send(ctx context.Context, env api.Envelope) error { c.writeMu.Lock() diff --git a/internal/server/ws/hub_test.go b/internal/server/ws/hub_test.go index ed8b04f..aa73a23 100644 --- a/internal/server/ws/hub_test.go +++ b/internal/server/ws/hub_test.go @@ -57,13 +57,18 @@ func TestWSHelloAndHeartbeat(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } defer c.CloseNow() + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() // Send hello. hello := api.HelloPayload{ @@ -125,13 +130,18 @@ func TestWSRejectsOldProtocol(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, }) if err != nil { t.Fatalf("dial: %v", err) } defer c.CloseNow() + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() hello := api.HelloPayload{ProtocolVersion: 0} // below minimum env, _ := api.Marshal(api.MsgHello, "", hello) @@ -170,6 +180,13 @@ func TestWSRejectsBadToken(t *testing.T) { _, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}}, }) + if res != nil { + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() + } if err == nil { t.Fatal("dial should fail") } diff --git a/internal/server/ws/jobhub.go b/internal/server/ws/jobhub.go index e509193..b6c138f 100644 --- a/internal/server/ws/jobhub.go +++ b/internal/server/ws/jobhub.go @@ -33,7 +33,7 @@ func NewJobHub() *JobHub { // the hub's set (so concurrent Broadcasts will reach it), but no // pump goroutine runs yet. The caller can prime the channel via Send // — useful for late-subscriber catch-up — and then call Run to start -// the pump. Run blocks until ctx is cancelled or conn dies, and +// the pump. Run blocks until ctx is canceled or conn dies, and // unregisters on return. type Subscriber struct { hub *JobHub @@ -73,7 +73,7 @@ func (s *Subscriber) Send(env api.Envelope) { } // Run pumps messages from the subscriber's channel onto conn until -// ctx is cancelled or conn dies. Unregisters on return. Caller is +// ctx is canceled or conn dies. Unregisters on return. Caller is // expected to invoke this from the goroutine that owns conn. func (s *Subscriber) Run(ctx context.Context, conn *Conn) { defer s.unregister() diff --git a/internal/store/audit.go b/internal/store/audit.go index b5cce69..bd82289 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -26,7 +26,7 @@ func (s *Store) AppendAudit(ctx context.Context, e AuditEntry) error { } // nullable returns nil for nil/empty *string so SQLite stores NULL. -// SQLite's driver treats Go nil as NULL but treats *string("") as ''. +// SQLite's driver treats Go nil as NULL but treats *string("") as ”. // We want NULL semantics for "absent." func nullable(p *string) any { if p == nil || *p == "" { diff --git a/internal/store/enrollment.go b/internal/store/enrollment.go index 4a2a0ff..3ad5cea 100644 --- a/internal/store/enrollment.go +++ b/internal/store/enrollment.go @@ -172,4 +172,3 @@ func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) n, _ := res.RowsAffected() return n, nil } - diff --git a/internal/store/fleet.go b/internal/store/fleet.go index 95307c4..95d25c0 100644 --- a/internal/store/fleet.go +++ b/internal/store/fleet.go @@ -57,7 +57,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) { if err != nil { return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var status string var n int @@ -70,7 +70,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) { fs.JobsLast24hSucceeded = n case "failed": fs.JobsLast24hFailed = n - case "cancelled": + case "cancelled": //nolint:misspell // matches the DB CHECK constraint and api.JobCancelled wire value fs.JobsLast24hCancelled = n } } diff --git a/internal/store/hosts.go b/internal/store/hosts.go index af7df00..bd6a24d 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -121,7 +121,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []Host for rows.Next() { h, err := scanHostRow(rows) @@ -150,11 +150,11 @@ func scanHost(row *sql.Row) (*Host, error) { func scanHostRow(s hostScanner) (*Host, error) { var h Host var ( - lastSeen, lastBackupAt sql.NullString - repoID, currentJob, lastBkSt sql.NullString - enrolled string - tags string - bwUp, bwDown sql.NullInt64 + lastSeen, lastBackupAt sql.NullString + repoID, currentJob, lastBkSt sql.NullString + enrolled string + tags string + bwUp, bwDown sql.NullInt64 ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, diff --git a/internal/store/jobs.go b/internal/store/jobs.go index 6e2d704..9438902 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -118,7 +118,7 @@ func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, l if err != nil { return nil, fmt.Errorf("store: list job logs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []JobLogLine for rows.Next() { var l JobLogLine @@ -143,15 +143,15 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) { started_at, finished_at, exit_code, stats, error, created_at FROM jobs WHERE id = ?`, id) var ( - j Job - schedID sql.NullString - actorID sql.NullString - startedAt sql.NullString - finishedAt sql.NullString - exitCode sql.NullInt64 - stats sql.NullString - errMsg sql.NullString - createdAt string + j Job + schedID sql.NullString + actorID sql.NullString + startedAt sql.NullString + finishedAt sql.NullString + exitCode sql.NullInt64 + stats sql.NullString + errMsg sql.NullString + createdAt string ) if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, &j.ActorKind, &actorID, &startedAt, &finishedAt, diff --git a/internal/store/maintenance.go b/internal/store/maintenance.go index 624af95..795a6b0 100644 --- a/internal/store/maintenance.go +++ b/internal/store/maintenance.go @@ -31,7 +31,7 @@ func (st *Store) GetRepoMaintenance(ctx context.Context, hostID string) (*HostRe check_cron, check_enabled, check_subset_pct FROM host_repo_maintenance WHERE host_id = ?`, hostID) var ( - m HostRepoMaintenance + m HostRepoMaintenance forgetEnabled, pruneEnabled, checkEnabled int ) err := row.Scan(&m.HostID, diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 0fd80bb..7f81642 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -15,9 +15,9 @@ var migrationsFS embed.FS // migration is one ordered SQL file from migrations/. type migration struct { - version int // parsed from filename prefix (0001, 0002, …) - name string // full filename, for error messages - sql string + version int // parsed from filename prefix (0001, 0002, …) + name string // full filename, for error messages + sql string } // loadMigrations reads every migrations/*.sql file in lexical order diff --git a/internal/store/pending.go b/internal/store/pending.go index eea9b84..42f2fdd 100644 --- a/internal/store/pending.go +++ b/internal/store/pending.go @@ -52,7 +52,7 @@ func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) ( if err != nil { return nil, fmt.Errorf("store: due pending runs: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []PendingRun{} for rows.Next() { var p PendingRun diff --git a/internal/store/schedules.go b/internal/store/schedules.go index f80bd4f..97f3186 100644 --- a/internal/store/schedules.go +++ b/internal/store/schedules.go @@ -144,7 +144,7 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche if err != nil { return nil, fmt.Errorf("store: list schedules: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []Schedule{} for rows.Next() { s, err := scanScheduleRow(rows) @@ -247,7 +247,7 @@ func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]str if err != nil { return nil, fmt.Errorf("store: read schedule junction: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []string{} for rows.Next() { var id string @@ -269,7 +269,7 @@ func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]str if err != nil { return nil, fmt.Errorf("store: schedules using group: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []string{} for rows.Next() { var id string diff --git a/internal/store/snapshots.go b/internal/store/snapshots.go index e01de90..9995830 100644 --- a/internal/store/snapshots.go +++ b/internal/store/snapshots.go @@ -51,7 +51,7 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [ if err != nil { return fmt.Errorf("store: prepare snapshot insert: %w", err) } - defer stmt.Close() + defer func() { _ = stmt.Close() }() refreshed := when.UTC().Format(time.RFC3339Nano) for _, snap := range snaps { @@ -92,7 +92,7 @@ func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snaps if err != nil { return nil, fmt.Errorf("store: list snapshots: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []Snapshot for rows.Next() { diff --git a/internal/store/snapshots_test.go b/internal/store/snapshots_test.go index 2870460..2abc9a3 100644 --- a/internal/store/snapshots_test.go +++ b/internal/store/snapshots_test.go @@ -30,20 +30,20 @@ func TestReplaceHostSnapshotsRoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) in := []Snapshot{ { - ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", - ShortID: "deadbeef", - Time: now.Add(-2 * time.Hour), - Hostname: "snap-host", - Paths: []string{"/etc", "/home"}, - Tags: []string{"daily"}, + ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", + ShortID: "deadbeef", + Time: now.Add(-2 * time.Hour), + Hostname: "snap-host", + Paths: []string{"/etc", "/home"}, + Tags: []string{"daily"}, SizeBytes: 4096, FileCount: 12, }, { - ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", - ShortID: "cafef00d", - Time: now.Add(-1 * time.Hour), - Hostname: "snap-host", - Paths: []string{"/etc"}, + ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", + ShortID: "cafef00d", + Time: now.Add(-1 * time.Hour), + Hostname: "snap-host", + Paths: []string{"/etc"}, SizeBytes: 8192, FileCount: 24, }, } @@ -129,9 +129,11 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) { // First a non-empty replace. if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{ - {ID: "1111111111111111111111111111111111111111111111111111111111111111", + { + ID: "1111111111111111111111111111111111111111111111111111111111111111", ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host", - Paths: []string{"/x"}}, + Paths: []string{"/x"}, + }, }, time.Now().UTC()); err != nil { t.Fatalf("replace 1: %v", err) } diff --git a/internal/store/sources.go b/internal/store/sources.go index 7c81b57..6ec3115 100644 --- a/internal/store/sources.go +++ b/internal/store/sources.go @@ -183,7 +183,7 @@ func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]S if err != nil { return nil, fmt.Errorf("store: list source groups: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() out := []SourceGroup{} for rows.Next() { g, err := scanSourceGroupRow(rows) @@ -220,10 +220,10 @@ type sourceGroupScanner interface { func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) { var ( - out SourceGroup - includes, excludes, retention string - conflict sql.NullString - createdAt, updatedAt string + out SourceGroup + includes, excludes, retention string + conflict sql.NullString + createdAt, updatedAt string ) err := s.Scan(&out.ID, &out.HostID, &out.Name, &includes, &excludes, &retention, diff --git a/internal/store/sources_test.go b/internal/store/sources_test.go index bc56013..2f6d7bb 100644 --- a/internal/store/sources_test.go +++ b/internal/store/sources_test.go @@ -177,7 +177,7 @@ func TestPendingRunQueue(t *testing.T) { now := time.Now().UTC() if err := s.EnqueuePendingRun(ctx, &PendingRun{ - ID: "01HPEND00000000000000001", + ID: "01HPEND00000000000000001", ScheduleID: schedID, SourceGroupID: gid, HostID: hostID, NextAttemptAt: now.Add(-time.Second), // already due ScheduledAt: now.Add(-time.Minute), diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c34a7e2..87aff9b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -34,10 +34,12 @@ func TestOpenAppliesMigrations(t *testing.T) { } // Spot-check a few tables exist with expected columns. - tables := []string{"users", "sessions", "hosts", "repos", + tables := []string{ + "users", "sessions", "hosts", "repos", "credentials", "schedules", "jobs", "job_logs", "snapshots", "alerts", "audit_log", - "enrollment_tokens", "host_schedule_version"} + "enrollment_tokens", "host_schedule_version", + } for _, tbl := range tables { row := s.DB().QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl) diff --git a/internal/store/types.go b/internal/store/types.go index e799f09..6f99f69 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -20,6 +20,7 @@ type User struct { // Role enumerates the access tiers from spec.md §7.2. type Role string +// Defined Role values, in descending order of privilege. const ( RoleAdmin Role = "admin" RoleOperator Role = "operator" @@ -73,12 +74,12 @@ type Host struct { // only. forget/prune/check are repo-level cadences on // HostRepoMaintenance, not schedule kinds. type Schedule struct { - ID string - HostID string - CronExpr string - Enabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID string + HostID string + CronExpr string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time // SourceGroupIDs is populated by ListSchedulesByHost (joins // schedule_source_groups) and accepted on Create / Update so the // caller passes the desired junction state in one shape. @@ -160,14 +161,14 @@ type HostRepoMaintenance struct { // PendingRun queues a missed cron tick (agent was offline) for the // server-side retry ticker to dispatch later. type PendingRun struct { - ID string - ScheduleID string - SourceGroupID string - HostID string - Attempt int - NextAttemptAt time.Time - ScheduledAt time.Time // original cron tick — forensic / audit - LastError string + ID string + ScheduleID string + SourceGroupID string + HostID string + Attempt int + NextAttemptAt time.Time + ScheduledAt time.Time // original cron tick — forensic / audit + LastError string } // EnrollmentToken is the issuer's view of a one-time token. diff --git a/tasks.md b/tasks.md index 632454d..df03958 100644 --- a/tasks.md +++ b/tasks.md @@ -273,4 +273,3 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **X-03** Periodic dependency updates (`dependabot` or `renovate`) - [ ] **X-04** Threat-model review at end of each phase - [ ] **X-05** Proper first-run onboarding UI: admin shouldn't need to `curl` `/api/bootstrap` by hand. Render the bootstrap form on the same login page (extra "setup token" field shown only while no admin user exists, hidden after); on submit POST to `/api/bootstrap`, then drop straight into a session. Surface the one-time token from the server log somewhere copy-able (or print a clickable URL with the token in the query string at first-run). Also: relax the 12-char password floor for the first-run path or document it in the form so `admin` doesn't silently fail validation. -- [ ] **X-06** Lint-baseline cleanup pass. `.golangci.yml` is now on the v2 schema; CI is gated with `only-new-issues: true` because the repo carries ~90 pre-existing findings (gofumpt drift × 31, misspell × 25, missing godoc on exported consts × 10, bodyclose × 6, errcheck × 12, errorlint/nilerr/unused × handful) accumulated before lint was actually wired into CI. Drive the count to zero in a dedicated PR (mostly mechanical: `gofumpt -w .`, fix typos, add comments, audit nilerr cases since those *might* be real bugs), then drop `only-new-issues: true` so future regressions are caught at the source. diff --git a/web/embed.go b/web/embed.go index 43d63dc..44dc2e3 100644 --- a/web/embed.go +++ b/web/embed.go @@ -7,5 +7,9 @@ package web import "embed" +// FS is the embedded view of every template + static asset under +// this package. Consumed by internal/server/ui (templates) and +// internal/server/http (static handler). +// //go:embed templates/* static/* var FS embed.FS -- 2.52.0 From 2d4000235553f3dacded64aedf441f9c9987e2c7 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 21:26:24 +0100 Subject: [PATCH 14/16] ci: enforce lint locally via pre-commit hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had a .pre-commit-config.yaml entry for golangci-lint already, but pinned to v1.61.0 — which doesn't grok the v2 schema we just migrated to, so it would crash if anyone ever ran it. Hence nobody did. Replace the third-party hook blocks with local hooks that call whatever tool is on the developer's PATH (gofumpt + go vet + golangci-lint). That way the version of each tool tracks what the developer would invoke by hand — no drift between hook config and binary. Add 'make setup' as a one-liner per-clone bootstrap: * installs gofumpt + golangci-lint via go install if missing * installs the pre-commit hooks via 'pre-commit install' end-of-file-fixer auto-fixed two existing files (web/static/css/ styles.css and ask.md) — trailing newlines, harmless. --- .pre-commit-config.yaml | 41 ++++++++++++++++++++++++++++++--------- Makefile | 11 ++++++++++- ask.md | 2 +- web/static/css/styles.css | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f7542c..7628ed0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,15 +11,38 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - - repo: https://github.com/dnephin/pre-commit-golang - rev: v0.5.1 + # Go-specific hooks. Local hooks (rather than third-party repos) so + # the version of each tool tracks whatever is on the developer's + # PATH, matching what they'd use to run the same checks by hand. + # Required tools: + # * go (toolchain matching go.mod) + # * gofumpt — `go install mvdan.cc/gofumpt@latest` + # * golangci-lint — `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6` + # + # Install + activate the hooks once per clone: + # pre-commit install + - repo: local hooks: - - id: go-fmt - - id: go-imports - - id: go-vet-mod - - id: go-mod-tidy + - id: gofumpt + name: gofumpt + description: Format Go files with gofumpt (stricter superset of gofmt) + entry: gofumpt -l -w + language: system + types: [go] + pass_filenames: true + + - id: go-vet + name: go vet + description: Run go vet across all packages + entry: go vet ./... + language: system + types: [go] + pass_filenames: false - - repo: https://github.com/golangci/golangci-lint - rev: v1.61.0 - hooks: - id: golangci-lint + name: golangci-lint + description: Run golangci-lint against the whole module (matches CI) + entry: golangci-lint run ./... + language: system + types: [go] + pass_filenames: false diff --git a/Makefile b/Makefile index 2970715..9a1f472 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo TAILWIND_INPUT := web/styles/input.css TAILWIND_OUTPUT := web/static/css/styles.css -.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch +.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch setup hooks help: @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @@ -58,6 +58,15 @@ test-race: ## Run tests with the race detector lint: ## Run golangci-lint golangci-lint run ./... +setup: hooks ## One-time per-clone setup (Go tools + git hooks) + @command -v gofumpt >/dev/null 2>&1 || go install mvdan.cc/gofumpt@latest + @command -v golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + @echo "==> setup complete: gofumpt, golangci-lint, pre-commit hooks installed" + +hooks: ## Install the pre-commit hooks defined in .pre-commit-config.yaml + @command -v pre-commit >/dev/null 2>&1 || { echo "pre-commit not found — install with 'pip install pre-commit' or 'brew install pre-commit'" >&2; exit 1; } + pre-commit install + fmt: ## Format with gofumpt + goimports gofumpt -w . goimports -local gitea.dcglab.co.uk/steve/restic-manager -w . diff --git a/ask.md b/ask.md index e31a232..96eaae9 100644 --- a/ask.md +++ b/ask.md @@ -5,4 +5,4 @@ All have restic installed on them I need to build a browser based management service that allows me to have a central single-plane-of-glass to monitor and manage all teh endpoints All endpoints will be enabled for SSH (unless other methods are better?) -Plan out how we would go about this please? \ No newline at end of file +Plan out how we would go about this please? diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 0f2fad1..d8105fa 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} -- 2.52.0 From 694d9d9bf39badb78b1d5b4093f1295def5a88c2 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 21:29:02 +0100 Subject: [PATCH 15/16] ci: bump golangci-lint to v2.5.0 (Go 1.25-built binary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2.1.6 release binary is built with Go 1.24, and golangci-lint refuses to load a config targeting a newer toolchain than itself ('Go language version (go1.24) used to build golangci-lint is lower than the targeted Go version (1.25.0)'). go.mod is on 1.25, so the binary needs to be too. Locally this didn't bite because 'go install …@v2.1.6' compiled v2.1.6 against the local Go 1.25 toolchain; CI uses the prebuilt release tarball which carries the build-time Go version. v2.5.0 is the first v2.x line built with Go 1.25 — pin in lockstep with go.mod going forward. --- .gitea/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 091b753..b37a625 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,10 +36,12 @@ jobs: cache: true - uses: golangci/golangci-lint-action@v7 with: - # v1.61 was built against Go 1.23 and refuses to load a - # config that targets a newer toolchain — go.mod is on 1.25. - # Bumping to a v2.x release built against current Go. - version: v2.1.6 + # Must be built against the same Go release as go.mod targets, + # otherwise the linter refuses to load with "Go language + # version used to build golangci-lint is lower than the + # targeted Go version". v2.5.0 is the first v2.x line built + # with Go 1.25; bump in lockstep with go.mod. + version: v2.5.0 args: --timeout=5m build: -- 2.52.0 From dd7b37a5c137b724c90128e504d4fa2c733e8d0e Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 21:31:47 +0100 Subject: [PATCH 16/16] lint: align local gofumpt rules with golangci-lint v2.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping CI to v2.5.0 surfaced two new gofumpt findings (in two test files that gofumpt v2.1.6 considered fine). Local re-format with the matching tool brings them in line. Pre-commit hook config: prepend $GOPATH/bin to PATH inside the hook entry so gofumpt + golangci-lint resolve when ~/go/bin isn't on the operator's interactive shell PATH (common — go install puts them there but PATH config varies). Without this, the hooks fail with 'Executable not found' even when the tools are installed. Pin the Makefile setup target to v2.5.0 so a fresh clone gets the same binary CI runs — keeps pre-commit and CI from drifting again. --- .pre-commit-config.yaml | 4 ++-- Makefile | 2 +- internal/server/http/p2r01_ws_test.go | 2 +- internal/server/ws/hub_test.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7628ed0..842b713 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: - id: gofumpt name: gofumpt description: Format Go files with gofumpt (stricter superset of gofmt) - entry: gofumpt -l -w + entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec gofumpt -l -w "$@"' -- language: system types: [go] pass_filenames: true @@ -42,7 +42,7 @@ repos: - id: golangci-lint name: golangci-lint description: Run golangci-lint against the whole module (matches CI) - entry: golangci-lint run ./... + entry: bash -c 'PATH="$(go env GOPATH)/bin:$PATH" exec golangci-lint run ./...' language: system types: [go] pass_filenames: false diff --git a/Makefile b/Makefile index 9a1f472..b24798f 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ lint: ## Run golangci-lint setup: hooks ## One-time per-clone setup (Go tools + git hooks) @command -v gofumpt >/dev/null 2>&1 || go install mvdan.cc/gofumpt@latest - @command -v golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + @command -v golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 @echo "==> setup complete: gofumpt, golangci-lint, pre-commit hooks installed" hooks: ## Install the pre-commit hooks defined in .pre-commit-config.yaml diff --git a/internal/server/http/p2r01_ws_test.go b/internal/server/http/p2r01_ws_test.go index ed53847..bc3c57a 100644 --- a/internal/server/http/p2r01_ws_test.go +++ b/internal/server/http/p2r01_ws_test.go @@ -102,7 +102,7 @@ func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (ho if err := st.SetHostCredentials(context.Background(), hostID, enc); err != nil { t.Fatalf("set creds: %v", err) } - return + return hostID, token } func sendHello(t *testing.T, c *websocket.Conn, hostname string) { diff --git a/internal/server/ws/hub_test.go b/internal/server/ws/hub_test.go index aa73a23..3753cc2 100644 --- a/internal/server/ws/hub_test.go +++ b/internal/server/ws/hub_test.go @@ -47,7 +47,7 @@ func setupTestHub(t *testing.T) (url string, token string, hostID string, st *st t.Fatalf("enroll: %v", err) } url = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent" - return + return url, token, hostID, st, hub } func TestWSHelloAndHeartbeat(t *testing.T) { -- 2.52.0