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
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>
389 lines
15 KiB
Vue
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-<date>.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>
|