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:
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