8aa635f0c1
Two fixes that close the loop on dashboard run-now and harden the
agent's restic invocation.
Default paths (interim until P2-01 schedules):
- 0003 migration adds default_paths TEXT NOT NULL DEFAULT '[]'
to hosts and to enrollment_tokens.
- Operator types paths in the Add-host form (textarea, one per
line). They ride on the enrol_token row alongside the
encrypted creds (paths aren't secret — plain JSON column).
- On consume, ConsumeEnrollmentToken still just burns the token;
the new GetEnrollmentTokenAttachments returns both the
re-bindable creds and the path list in one round trip, the
handler transfers them onto the new host row inside CreateHost.
- The dashboard's Run-now and host-detail's "Run backup now"
button now read Host.DefaultPaths and pass them to dispatchJob.
A host with no default paths returns 400 with a friendly
"no paths set" message instead of dispatching a doomed
`restic backup` with no positional args.
- Doc comments explicitly call this out as a Phase 1 interim —
schedules supersede.
Restic env hygiene:
- envSlice() previously omitted HOME / XDG_CACHE_HOME, which
bit the smoke runs whenever the agent was launched outside
systemd (restic refused to start: "neither $XDG_CACHE_HOME
nor $HOME are defined"). Now both are set explicitly: prefer
Env.ExtraEnv overrides, fall back to the agent process's own
HOME, and finally to /var/lib/restic-manager.
- Comment makes the env policy explicit: parent's RESTIC_* /
AWS_* / B2_* env is filtered out by design — control-plane
is the unambiguous source of truth.
JS bug fix in the live log page:
- {{$job.ID | printf "%q"}} produced a literal-quoted JS string,
which then went into the WS URL as ".../jobs/"<ID>"/stream"
→ 404. Switched to '{{$job.ID}}' inside the literal so
html/template's auto-escape does the right thing. Verified
end-to-end: dashboard "Run now" → live progress + log lines
arrive over the WS → succeeded pill renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
11 KiB
HTML
263 lines
11 KiB
HTML
{{define "title"}}{{.Title}}{{end}}
|
|
|
|
{{define "content"}}
|
|
{{$page := .Page}}
|
|
{{$job := $page.Job}}
|
|
{{$host := $page.Host}}
|
|
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
|
|
|
|
<div class="crumbs">
|
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
|
<a href="/hosts/{{$host.ID}}">{{$host.Name}}</a><span class="sep">/</span>
|
|
<span class="text-ink-mid">job {{slice $job.ID 0 8}}…{{slice $job.ID (sub (len $job.ID) 4) (len $job.ID)}}</span>
|
|
</div>
|
|
|
|
{{/* ---------- header ---------- */}}
|
|
<div class="flex items-start justify-between mt-3.5">
|
|
<div>
|
|
<div class="flex items-center gap-3">
|
|
{{if $page.IsActive}}
|
|
<span class="dot pulse" style="background: var(--accent);"></span>
|
|
{{else if eq $job.Status "succeeded"}}
|
|
<span class="dot dot-online"></span>
|
|
{{else if eq $job.Status "failed"}}
|
|
<span class="dot dot-failed"></span>
|
|
{{else}}
|
|
<span class="dot dot-offline"></span>
|
|
{{end}}
|
|
<h1 class="text-[22px] font-medium tracking-[-0.01em]">
|
|
{{$job.Kind}} <span class="text-ink-fade">·</span>
|
|
<span class="mono text-ink font-medium">{{$host.Name}}</span>
|
|
</h1>
|
|
{{if eq $job.Status "queued"}}
|
|
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
|
style="background: color-mix(in oklch, var(--ink-mid), transparent 88%); color: var(--ink-mid); border: 1px solid var(--line-soft);">queued</span>
|
|
{{else if eq $job.Status "running"}}
|
|
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
|
style="background: color-mix(in oklch, var(--accent), transparent 88%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 70%);">running</span>
|
|
{{else if eq $job.Status "succeeded"}}
|
|
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
|
style="background: color-mix(in oklch, var(--ok), transparent 88%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">succeeded</span>
|
|
{{else if eq $job.Status "failed"}}
|
|
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
|
style="background: color-mix(in oklch, var(--bad), transparent 88%); color: var(--bad); border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);">failed</span>
|
|
{{else}}
|
|
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
|
|
style="background: color-mix(in oklch, var(--warn), transparent 88%); color: var(--warn); border: 1px solid color-mix(in oklch, var(--warn), transparent 70%);">{{$job.Status}}</span>
|
|
{{end}}
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-2.5 text-[12.5px] text-ink-mute">
|
|
<span>job <span class="mono text-ink-mid">{{$job.ID}}</span></span>
|
|
{{if $job.StartedAt}}
|
|
<span class="text-ink-fade">·</span>
|
|
<span>started <span class="mono text-ink-mid">{{relTime $job.StartedAt}}</span></span>
|
|
{{end}}
|
|
{{if $job.FinishedAt}}
|
|
<span class="text-ink-fade">·</span>
|
|
<span>finished <span class="mono text-ink-mid">{{relTime $job.FinishedAt}}</span></span>
|
|
{{end}}
|
|
{{if $job.ExitCode}}
|
|
<span class="text-ink-fade">·</span>
|
|
<span>exit code <span class="mono {{if eq $job.Status "failed"}}text-bad{{else}}text-ink-mid{{end}}">{{derefInt $job.ExitCode}}</span></span>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{{if $page.IsActive}}
|
|
<button class="btn btn-danger" id="cancel-btn"
|
|
hx-post="/api/jobs/{{$job.ID}}/cancel"
|
|
hx-swap="none">Cancel job</button>
|
|
{{else}}
|
|
<a href="/hosts/{{$host.ID}}" class="btn">Back to host</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
{{/* ---------- progress (running only) ---------- */}}
|
|
{{if $page.IsActive}}
|
|
<div class="mt-7" id="progress-block">
|
|
<div class="flex items-center justify-between mb-2.5">
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<span class="mono text-ink font-medium" id="progress-pct">—</span>
|
|
<span class="text-ink-mute" id="progress-bytes"></span>
|
|
</div>
|
|
<div class="text-sm text-ink-mute" id="progress-rate"></div>
|
|
</div>
|
|
<div class="progress-track">
|
|
<div class="progress-fill" id="progress-fill" style="width: 0%;"></div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* ---------- failure summary (failed only) ---------- */}}
|
|
{{if eq $job.Status "failed"}}
|
|
<div class="panel mt-6 p-4 rounded-[7px]" style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
|
|
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-bad mb-1.5">Failure</div>
|
|
{{if $job.Error}}
|
|
<p class="mono text-[13px] text-ink leading-[1.6]">{{deref $job.Error}}</p>
|
|
{{else}}
|
|
<p class="text-ink-mute text-[13px]">No error message captured. Inspect the log below for details.</p>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* ---------- log viewer ---------- */}}
|
|
<div class="mt-6">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Stream</h2>
|
|
<span class="text-[11.5px] text-ink-fade" id="stream-status">
|
|
{{if $page.IsActive}}
|
|
following · auto-scroll on
|
|
{{else}}
|
|
<span class="text-ink-fade">complete · {{len $page.Logs}} lines</span>
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-ghost" id="follow-btn" style="display: none;">⇢ Follow</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="log" id="log-container">
|
|
<div id="log-stream">
|
|
{{range $page.Logs}}
|
|
<div class="log-line">
|
|
<span class="log-ts">{{.TS.Format "15:04:05.000"}}</span>
|
|
<span class="log-tag">{{if eq .Stream "stdout"}}OUT{{else if eq .Stream "stderr"}}ERR{{else}}EVENT{{end}}</span>
|
|
<span class="log-stream-{{.Stream}}">{{.Payload}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{if and $page.IsActive (eq (len $page.Logs) 0)}}
|
|
<div class="log-line">
|
|
<span class="log-ts" style="color: var(--accent);">···</span>
|
|
<span class="log-tag" style="color: var(--accent);">…</span>
|
|
<span style="color: var(--accent);">awaiting agent output</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{{if $page.IsActive}}
|
|
<script>
|
|
(function() {
|
|
const jobID = '{{$job.ID}}';
|
|
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const ws = new WebSocket(`${wsProto}://${location.host}/api/jobs/${jobID}/stream`);
|
|
const stream = document.getElementById('log-stream');
|
|
const container = document.getElementById('log-container');
|
|
const fill = document.getElementById('progress-fill');
|
|
const pct = document.getElementById('progress-pct');
|
|
const bytes = document.getElementById('progress-bytes');
|
|
const rate = document.getElementById('progress-rate');
|
|
const status = document.getElementById('stream-status');
|
|
const followBtn = document.getElementById('follow-btn');
|
|
|
|
let autoScroll = true;
|
|
// If the user scrolls up, stop auto-scrolling and surface a "Follow" button.
|
|
container.addEventListener('scroll', () => {
|
|
const atBottom = container.scrollHeight - container.clientHeight - container.scrollTop < 8;
|
|
if (!atBottom && autoScroll) {
|
|
autoScroll = false;
|
|
followBtn.style.display = '';
|
|
status.textContent = 'paused · scroll to follow';
|
|
} else if (atBottom && !autoScroll) {
|
|
autoScroll = true;
|
|
followBtn.style.display = 'none';
|
|
status.textContent = 'following · auto-scroll on';
|
|
}
|
|
});
|
|
followBtn.addEventListener('click', () => {
|
|
container.scrollTop = container.scrollHeight;
|
|
});
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
function fmtTs(iso) {
|
|
// Show HH:MM:SS.mmm in local time. Falls back to iso if parsing fails.
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return iso;
|
|
const pad = (n, w) => String(n).padStart(w, '0');
|
|
return `${pad(d.getHours(),2)}:${pad(d.getMinutes(),2)}:${pad(d.getSeconds(),2)}.${pad(d.getMilliseconds(),3)}`;
|
|
}
|
|
function fmtBytes(n) {
|
|
if (!n) return '0 B';
|
|
const u = ['B','kB','MB','GB','TB'];
|
|
let i = 0;
|
|
while (n >= 1000 && i < u.length-1) { n /= 1000; i++; }
|
|
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i];
|
|
}
|
|
|
|
function appendLine(p) {
|
|
// Drop the "awaiting" placeholder once real lines arrive.
|
|
if (stream.children.length === 1 && stream.firstElementChild.textContent.includes('awaiting agent')) {
|
|
stream.firstElementChild.remove();
|
|
}
|
|
const tag = p.stream === 'stdout' ? 'OUT' : p.stream === 'stderr' ? 'ERR' : 'EVENT';
|
|
const line = document.createElement('div');
|
|
line.className = 'log-line';
|
|
line.innerHTML =
|
|
`<span class="log-ts">${fmtTs(p.ts)}</span>` +
|
|
`<span class="log-tag">${tag}</span>` +
|
|
`<span class="log-stream-${p.stream}">${escapeHtml(p.payload)}</span>`;
|
|
stream.appendChild(line);
|
|
if (autoScroll) container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
ws.onmessage = (ev) => {
|
|
let env;
|
|
try { env = JSON.parse(ev.data); } catch { return; }
|
|
let p;
|
|
try { p = env.payload ? JSON.parse(JSON.stringify(env.payload)) : {}; } catch { p = {}; }
|
|
// The server sends payload as a JSON object literal, but Go wire
|
|
// wraps it as raw JSON in the envelope. After JSON.parse(ev.data)
|
|
// payload is already an object — no second parse needed.
|
|
p = env.payload || {};
|
|
switch (env.type) {
|
|
case 'log.stream':
|
|
appendLine(p);
|
|
break;
|
|
case 'job.progress': {
|
|
const percent = Math.round((p.percent_done || 0) * 100);
|
|
fill.style.width = percent + '%';
|
|
pct.textContent = percent + '%';
|
|
const totals = [];
|
|
if (p.bytes_done && p.total_bytes) totals.push(`${fmtBytes(p.bytes_done)} of ${fmtBytes(p.total_bytes)}`);
|
|
if (p.files_done && p.total_files) totals.push(`${p.files_done.toLocaleString()} of ${p.total_files.toLocaleString()} files`);
|
|
bytes.textContent = totals.length ? '· ' + totals.join(' · ') : '';
|
|
const parts = [];
|
|
if (p.throughput_bps) parts.push(`${fmtBytes(p.throughput_bps)}/s`);
|
|
if (p.eta_seconds) parts.push(`ETA ${Math.floor(p.eta_seconds/60)}m ${p.eta_seconds%60}s`);
|
|
rate.textContent = parts.join(' · ');
|
|
break;
|
|
}
|
|
case 'job.started':
|
|
// Already shown in the header on a fresh page load; if we
|
|
// arrive before the job moves to "running" the next progress
|
|
// tick will paint it.
|
|
break;
|
|
case 'job.finished':
|
|
// Reload to render the final-state header (stats / exit code
|
|
// / end-of-stream markers come from the server).
|
|
setTimeout(() => location.reload(), 600);
|
|
break;
|
|
}
|
|
};
|
|
ws.onerror = () => { status.textContent = 'connection error · reload to retry'; };
|
|
ws.onclose = () => {
|
|
if (status.textContent.startsWith('following')) {
|
|
status.textContent = 'stream closed';
|
|
}
|
|
};
|
|
})();
|
|
</script>
|
|
{{end}}
|
|
|
|
{{end}}
|