Files
FabledGrimoire/frontend/src/components/BackupScheduleSettings.vue
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -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>grimoire-&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="grimoire" />
</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>