P2R-02 slice 3: Schedules tab — slim list, new/edit form, delete, Run-now
CI / Test (linux/amd64) (pull_request) Failing after 44s
CI / Lint (pull_request) Failing after 13s
CI / Build (windows/amd64) (pull_request) Successful in 19s
CI / Build (linux/amd64) (pull_request) Successful in 19s
CI / Build (linux/arm64) (pull_request) Successful in 25s
CI / Test (linux/amd64) (pull_request) Failing after 44s
CI / Lint (pull_request) Failing after 13s
CI / Build (windows/amd64) (pull_request) Successful in 19s
CI / Build (linux/amd64) (pull_request) Successful in 19s
CI / Build (linux/arm64) (pull_request) Successful in 25s
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.
This commit is contained in:
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -2,12 +2,72 @@
|
||||
|
||||
{{define "content"}}
|
||||
{{template "host_chrome" .}}
|
||||
{{$page := .Page}}
|
||||
{{$host := $page.Host}}
|
||||
{{$groupNames := $page.GroupNames}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
|
||||
<div class="empty-state">
|
||||
<h3 class="text-base font-medium tracking-[-0.005em]">Schedules tab — coming next.</h3>
|
||||
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
|
||||
The slim-schedule list and form land in P2R-02 slice 3.
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[760px]">
|
||||
A schedule is a cron expression pointing at one or more source groups. When it fires, the agent runs a separate
|
||||
<span class="mono text-ink-mid">restic backup</span> per chosen group — independent jobs, independent snapshots,
|
||||
independent retention. Failure of one group doesn't fail the others.
|
||||
</p>
|
||||
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary whitespace-nowrap">+ New schedule</a>
|
||||
</div>
|
||||
|
||||
{{if eq (len $page.Schedules) 0}}
|
||||
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
|
||||
<h3 class="text-base font-medium tracking-[-0.005em]">No schedules yet.</h3>
|
||||
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
|
||||
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.
|
||||
</p>
|
||||
<div class="mt-5">
|
||||
<a href="/hosts/{{$host.ID}}/schedules/new" class="btn btn-primary">+ New schedule</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="panel rounded-[7px] overflow-hidden">
|
||||
<div class="schd-row head hairline">
|
||||
<div>Status</div>
|
||||
<div>Cron</div>
|
||||
<div>Sources</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{{range $i, $sc := $page.Schedules}}
|
||||
<div class="schd-row {{if not (eq $i 0)}}hairline{{end}}">
|
||||
<div>
|
||||
{{if $sc.Enabled}}
|
||||
<span class="mono text-[11px] text-ok">enabled</span>
|
||||
{{else}}
|
||||
<span class="mono text-[11px] text-ink-fade">paused</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mono {{if $sc.Enabled}}text-ink{{else}}text-ink-mute{{end}}">{{$sc.CronExpr}}</div>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{{range $sc.SourceGroupIDs}}
|
||||
{{$name := index $groupNames .}}
|
||||
<span class="tag" style="border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent); {{if not $sc.Enabled}}opacity: 0.6;{{end}}">{{if $name}}{{$name}}{{else}}<span class="text-ink-fade">unknown</span>{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
{{if and $sc.Enabled (eq $host.Status "online")}}
|
||||
<button class="btn btn-primary"
|
||||
hx-post="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/run"
|
||||
hx-swap="none"
|
||||
hx-disabled-elt="this">Run now</button>
|
||||
{{end}}
|
||||
<a href="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/edit" class="btn">Edit</a>
|
||||
<form method="post" action="/hosts/{{$host.ID}}/schedules/{{$sc.ID}}/delete" style="display: inline;"
|
||||
onsubmit="return confirm('Delete this schedule? Existing snapshots are not affected.');">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{template "host_chrome" .}}
|
||||
{{$page := .Page}}
|
||||
{{$host := $page.Host}}
|
||||
{{$f := $page.Form}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-24 pt-6">
|
||||
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
|
||||
{{if $page.IsNew}}New schedule{{else}}Edit schedule{{end}}
|
||||
</h1>
|
||||
|
||||
{{if $page.Error}}
|
||||
<div class="mt-5 panel rounded-[6px] px-4 py-3 text-[13px]"
|
||||
style="border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); color: var(--ink);">
|
||||
{{$page.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="{{$page.SaveAction}}" class="panel rounded-[7px] p-7 mt-6">
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
|
||||
<div class="col-span-7">
|
||||
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">When</h3>
|
||||
<label class="field-label" for="cron">Cron expression</label>
|
||||
<input type="text" id="cron" name="cron" class="field mono" value="{{$f.CronExpr}}" required autofocus />
|
||||
<div class="flex flex-wrap gap-1.5 mt-2.5" id="cron-presets">
|
||||
{{range list "0 3 * * *" "@hourly" "0 */6 * * *" "0 3 * * 0" "0 3 1 * *"}}
|
||||
<span class="preset-chip" data-cron="{{.}}">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="field-help mt-2.5">
|
||||
Standard 5-field cron with descriptors. Server validates with the same parser the agent uses to fire — what saves here is what runs.
|
||||
</div>
|
||||
|
||||
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-6 pt-4 border-t border-line-soft">
|
||||
What — pick one or more source groups
|
||||
</h3>
|
||||
{{if eq (len $page.AvailableGroups) 0}}
|
||||
<div class="text-[12.5px] text-ink-mute leading-[1.6]">
|
||||
This host has no source groups yet — <a href="/hosts/{{$host.ID}}/sources/new" class="text-accent underline">create one first</a>
|
||||
so this schedule has something to back up.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="grid grid-cols-1 gap-1.5" id="group-pickers">
|
||||
{{range $page.AvailableGroups}}
|
||||
{{$checked := index $page.SelectedGroupIDs .ID}}
|
||||
<label class="picker {{if $checked}}checked{{end}}">
|
||||
<input type="checkbox" name="source_group_ids" value="{{.ID}}" {{if $checked}}checked{{end}} />
|
||||
<span class="check"></span>
|
||||
<span class="mono text-ink flex-1">{{.Name}}</span>
|
||||
<span class="text-[11.5px] text-ink-fade">
|
||||
{{len .Includes}} include{{if ne (len .Includes) 1}}s{{end}} ·
|
||||
{{.RetentionPolicy.Summary}}
|
||||
</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="field-help mt-2.5">
|
||||
Each picked group runs as a separate <span class="mono text-ink-mid">restic backup</span> with its own tag — its own snapshot, its own retention. Pick multiple to fire them all on the same cron tick.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-6 pt-4 border-t border-line-soft">Status</h3>
|
||||
<label class="flex items-center gap-2.5 text-[13px] cursor-pointer">
|
||||
<input type="checkbox" name="enabled" value="1" {{if $f.Enabled}}checked{{end}} class="w-3.5 h-3.5" />
|
||||
<span>Enabled</span>
|
||||
<span class="text-ink-fade">— uncheck to keep the row but stop firing.</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-line-soft flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create schedule{{else}}Save changes{{end}}</button>
|
||||
<a href="/hosts/{{$host.ID}}/schedules" class="btn btn-lg">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="col-span-5 border-l border-line-soft pl-6">
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3">No paths, no retention, no kind</div>
|
||||
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6]">
|
||||
That stuff lives on source groups now. A schedule's only job is to be the cron expression and to point at the groups it should fire.
|
||||
Change a group's retention, every schedule that points at it inherits the change without further edits.
|
||||
</p>
|
||||
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6] mt-3">
|
||||
<strong>Forget / prune / check are not schedule kinds anymore.</strong>
|
||||
They run on host-level cadences from the
|
||||
<a href="/hosts/{{$host.ID}}/repo" class="text-accent underline">Repo tab</a>.
|
||||
</p>
|
||||
<div class="panel rounded-[6px] px-4 py-3.5 mt-4" style="background: var(--bg);">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-ink-fade">If the agent is offline at fire time</div>
|
||||
<p class="text-[12px] text-ink-mid mt-1.5 leading-[1.55]">
|
||||
Server retries per the group's retry policy (max attempts + exponential backoff).
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Preset chip → fill cron input. Group picker → toggle the
|
||||
// "checked" class so the visual state tracks the underlying box.
|
||||
(function () {
|
||||
var cronInput = document.getElementById('cron');
|
||||
document.querySelectorAll('#cron-presets .preset-chip').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () { cronInput.value = chip.dataset.cron; });
|
||||
});
|
||||
document.querySelectorAll('#group-pickers .picker').forEach(function (label) {
|
||||
var box = label.querySelector('input[type="checkbox"]');
|
||||
box.addEventListener('change', function () {
|
||||
label.classList.toggle('checked', box.checked);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user