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

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:
2026-05-03 11:55:16 +01:00
parent dede74fd3a
commit 67ca769686
5 changed files with 549 additions and 15 deletions
+310 -10
View File
@@ -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
+58
View File
@@ -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);
+64 -4
View File
@@ -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}}
+116
View File
@@ -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}}