Files
FabledGrimoire/frontend/src/components/BackupScheduleSettings.vue
T
bvandeusenandClaude Opus 5.5 e99f1169f7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
rename(547): what people read says Grimoire
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:46:37 -04:00

389 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
checkBackupDestination, getBackups, runBackupNow, saveBackups,
type BackupChanges, type BackupConfig, type BackupRun, type BackupsOverview,
} from "@/api/backups";
import { relativeTime } from "@/composables/useRelativeTime";
import { formatBytes } from "@/utils/bytes";
import { fmtStamp } from "@/utils/dateFormat";
import { useToastStore } from "@/stores/toast";
/**
* Scheduled backups: one dated, uncompressed tar per run,
* delivered to a folder or an S3 bucket. The page answers "is my data backed
* up?" first — the verdict, then anything wrong — and the settings follow.
*/
const toastStore = useToastStore();
const data = ref<BackupsOverview | null>(null);
const loading = ref(true);
const loadError = ref("");
const form = ref<BackupConfig | null>(null);
// The secret is never sent back by the server; this holds only what the
// admin types, and is sent only when non-empty.
const secretInput = ref("");
const saving = ref(false);
const checking = ref(false);
const checkResult = ref<{ ok: boolean; message: string } | null>(null);
const starting = ref(false);
const showAllRuns = ref(false);
const RUNS_SHOWN = 8;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let unmounted = false;
async function load() {
loadError.value = "";
try {
const fresh = await getBackups();
data.value = fresh;
// Keep the admin's unsaved edits; take the server's config only on the
// first load or right after a save.
if (!form.value) form.value = { ...fresh.config };
} catch (e: unknown) {
loadError.value = apiErrorMessage(e, "Could not load the backup settings");
} finally {
loading.value = false;
schedulePoll();
}
}
// While a backup runs, follow it. Re-armed from load()'s finally, never after the work,
// so one failed read does not stop the page following the run.
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = null;
if (!unmounted && data.value?.running) pollTimer = setTimeout(load, 3000);
}
onMounted(load);
onBeforeUnmount(() => {
unmounted = true;
if (pollTimer) clearTimeout(pollTimer);
});
const hourLabel = (h: number) => `${String(h).padStart(2, "0")}:00 UTC`;
// ── The verdict, and what is wrong ─────────────────────────────────────────
const verdict = computed(() => {
const d = data.value;
if (!d) return { tone: "", text: "" };
if (d.running) return { tone: "", text: "Backing up now…" };
if (!d.config.enabled) {
return { tone: "muted", text: "Scheduled backups are off. Nothing is backed up unless you run one." };
}
const ok = d.last_success;
if (d.overdue) {
return {
tone: "error",
text: ok
? `Overdue — the last backup that worked was ${relativeTime(ok.finished_at)} (${fmtStamp(ok.finished_at)}).`
: "Overdue — no backup has worked yet.",
};
}
if (!ok) return { tone: "", text: `On — the first backup runs at ${hourLabel(d.config.hour)}.` };
return {
tone: "ok",
text: `Backed up ${relativeTime(ok.finished_at)}: ${ok.file}, ${formatBytes(ok.size_bytes ?? 0)}, to ${ok.where}.`,
};
});
const lastFailure = computed(() => {
const last = data.value?.last_run;
return last && last.status === "failed" ? last : null;
});
const secretUnreadable = computed(() => {
const s = data.value?.secret;
return !!s && s.set && !s.readable;
});
const visibleRuns = computed<BackupRun[]>(() => {
const runs = data.value?.runs ?? [];
return showAllRuns.value ? runs : runs.slice(0, RUNS_SHOWN);
});
const hiddenRuns = computed(() => Math.max(0, (data.value?.runs.length ?? 0) - RUNS_SHOWN));
// ── Actions ────────────────────────────────────────────────────────────────
function changes(): BackupChanges {
const out: BackupChanges = { ...(form.value as BackupConfig) };
if (secretInput.value) out.s3_secret = secretInput.value;
return out;
}
async function save() {
if (!form.value) return;
saving.value = true;
try {
data.value = await saveBackups(changes());
form.value = { ...data.value.config };
secretInput.value = "";
toastStore.show("Backup settings saved");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not save the backup settings"), "error");
} finally {
saving.value = false;
}
}
async function removeSecret() {
try {
data.value = await saveBackups({ clear_s3_secret: true });
toastStore.show("S3 secret removed");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not remove the secret"), "error");
}
}
async function check() {
if (!form.value) return;
checking.value = true;
checkResult.value = null;
try {
checkResult.value = await checkBackupDestination(changes());
} catch (e: unknown) {
checkResult.value = { ok: false, message: apiErrorMessage(e, "The check could not be run") };
} finally {
checking.value = false;
}
}
async function backUpNow() {
starting.value = true;
try {
await runBackupNow();
if (data.value) data.value.running = true;
schedulePoll();
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not start a backup"), "error");
} finally {
starting.value = false;
}
}
</script>
<template>
<div class="backups">
<p v-if="loading && !data" class="state">Loading…</p>
<p v-else-if="loadError && !data" class="state state-error" role="alert">
{{ loadError }}
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<template v-else-if="data && form">
<p :class="['verdict', `verdict-${verdict.tone}`]">{{ verdict.text }}</p>
<p v-if="lastFailure && !data.running" class="problem" role="alert">
The last run failed {{ relativeTime(lastFailure.finished_at) }}: {{ lastFailure.error }}
</p>
<p v-if="secretUnreadable" class="problem" role="alert">
The stored S3 secret can't be opened here — it was saved on an instance with a different
SECRET_KEY. Enter it again below.
</p>
<ul v-if="data.config.enabled && data.problems.length" class="problem-list" role="alert">
<li v-for="p in data.problems" :key="p">{{ p }}</li>
</ul>
<p class="intro">
Each run adds one dated file, <code>scribe-&lt;date&gt;.tar</code>, holding every table
as plain lines. It is not compressed, so a deduplicating tool such as restic stores only what
changed since the day before. Grimoire never deletes old backups — the destination keeps and
thins the history. Sharing, API keys and forge connections are not included.
</p>
<div class="row">
<label class="check">
<input v-model="form.enabled" type="checkbox" />
Back up daily
</label>
<label class="inline-field">
at
<select v-model.number="form.hour" class="fs-input" aria-label="Backup hour (UTC)">
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ hourLabel(h - 1) }}</option>
</select>
</label>
</div>
<fieldset class="destination">
<legend>Send each backup to</legend>
<label class="check">
<input v-model="form.destination" type="radio" value="folder" />
A folder
</label>
<label class="check">
<input v-model="form.destination" type="radio" value="s3" />
An S3 bucket
</label>
</fieldset>
<div v-if="form.destination === 'folder'" class="fields">
<label class="field">
<span>Folder</span>
<input v-model.trim="form.folder_path" class="fs-input" placeholder="/backups" />
<span class="field-hint">
A path inside the Grimoire container — usually a share mounted there that a backup
service watches.
</span>
</label>
</div>
<div v-else class="fields">
<label class="field">
<span>Address</span>
<input v-model.trim="form.s3_endpoint" class="fs-input" placeholder="https://backup.example.com" />
<span class="field-hint">The S3 service itself, without the bucket. Buckets are addressed by path.</span>
</label>
<label class="field">
<span>Bucket</span>
<input v-model.trim="form.s3_bucket" class="fs-input" placeholder="scribe" />
</label>
<label class="field">
<span>Access key</span>
<input v-model.trim="form.s3_access_key" class="fs-input" autocomplete="off" />
</label>
<label class="field">
<span>Secret key</span>
<input
v-model="secretInput"
type="password"
class="fs-input"
autocomplete="new-password"
:placeholder="data.secret.set && data.secret.readable ? 'Saved — type a new one to replace it' : ''"
/>
<span class="field-hint">
Stored encrypted, and never included in a backup. A key that may only add files is enough.
<button
v-if="data.secret.set"
type="button"
class="btn-text"
@click="removeSecret"
>Remove the saved secret</button>
</span>
</label>
<label class="field">
<span>Region</span>
<input v-model.trim="form.s3_region" class="fs-input" placeholder="us-east-1" />
</label>
<label class="check">
<input v-model="form.s3_verify_tls" type="checkbox" />
Check the address's TLS certificate
</label>
</div>
<div class="actions">
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-secondary" :disabled="checking" @click="check">
{{ checking ? "Checking…" : "Test destination" }}
</button>
<button
type="button"
class="btn-secondary"
:disabled="starting || data.running"
@click="backUpNow"
>
{{ data.running ? "Backing up…" : "Back up now" }}
</button>
</div>
<p v-if="checkResult" :class="['check-result', checkResult.ok ? 'check-ok' : 'check-bad']" role="status">
{{ checkResult.message }}
</p>
<h3 class="runs-label">Recent runs</h3>
<p v-if="!data.runs.length" class="state">None yet.</p>
<div v-else class="runs-scroll">
<table class="runs">
<thead>
<tr><th>When</th><th>Result</th><th>File</th><th class="num">Size</th><th class="num">Took</th></tr>
</thead>
<tbody>
<tr v-for="r in visibleRuns" :key="r.started_at" :class="{ failed: r.status === 'failed' }">
<td :title="fmtStamp(r.started_at)">
{{ relativeTime(r.started_at) }}
<span v-if="r.trigger === 'manual'" class="by-hand">by hand</span>
</td>
<td>{{ r.status === "ok" ? "Backed up" : r.error }}</td>
<td><code v-if="r.file">{{ r.file }}</code><span v-else>—</span></td>
<td class="num">{{ r.size_bytes != null ? formatBytes(r.size_bytes) : "—" }}</td>
<td class="num">{{ r.duration_s }} s</td>
</tr>
</tbody>
</table>
<button v-if="hiddenRuns && !showAllRuns" type="button" class="btn-text" @click="showAllRuns = true">
+{{ hiddenRuns }} earlier
</button>
</div>
</template>
</div>
</template>
<style scoped>
.backups { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state-error { color: var(--fs-error); }
.verdict { margin: 0; font-size: var(--fs-size-body); color: var(--fs-text-primary); }
.verdict-ok { color: var(--fs-success); }
.verdict-error { color: var(--fs-error); }
.verdict-muted { color: var(--fs-text-secondary); }
.problem, .problem-list {
margin: 0;
font-size: var(--fs-size-body-sm);
color: var(--fs-error);
}
.problem-list { padding-left: var(--fs-space-4); }
.intro { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.intro code, .runs code { font-family: var(--fs-font-mono); font-size: var(--fs-size-tiny); }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3); }
.check { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.inline-field { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.inline-field select { width: auto; }
.destination {
margin: 0; padding: 0; border: 0;
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3);
}
.destination legend {
float: left; margin-right: var(--fs-space-2);
font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
}
.fields { display: flex; flex-direction: column; gap: var(--fs-space-3); max-width: 32rem; }
.field { display: flex; flex-direction: column; gap: var(--fs-space-1); }
.field > span:first-child { font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.actions { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.check-result { margin: 0; font-size: var(--fs-size-body-sm); }
.check-ok { color: var(--fs-success); }
.check-bad { color: var(--fs-error); }
.runs-label {
margin: var(--fs-space-2) 0 0;
font-size: var(--fs-size-tiny); font-weight: normal;
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
color: var(--fs-text-tertiary);
}
.runs-scroll { overflow-x: auto; }
.runs { width: 100%; border-collapse: collapse; font-size: var(--fs-size-body-sm); }
.runs th {
text-align: left; font-weight: normal;
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
padding: var(--fs-space-1) var(--fs-space-2);
border-bottom: 1px solid var(--fs-border-color);
}
.runs td {
padding: var(--fs-space-1) var(--fs-space-2);
color: var(--fs-text-primary);
border-bottom: 1px solid var(--fs-border-color);
vertical-align: baseline;
}
.runs .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.runs tr.failed td { color: var(--fs-error); }
.by-hand { margin-left: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
</style>