feat: Minstrel backs itself up nightly to a folder or an S3 bucket, off until an admin switches it on (#6773)
release / web (push) Failing after 15s
release / govulncheck (push) Successful in 16s
release / go (push) Failing after 41s
release / integration (push) Failing after 3m29s
release / android (push) Successful in 4m7s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (push) Canceled after 5m34s

Admin › Backups shows a verdict first (off, not set up, waiting, backed up,
overdue, failing since when), Back up now, Test destination and the latest
runs, with the settings beside them.

- backup.Service: one run at a time, bounded at 30 minutes; the schedule
  ticks every 5 minutes, catches up a slot missed while the server was down,
  and retries a failure after an hour and then after as long as it has been
  failing. Runs are recorded in backup_runs, pruned after 90 days; a run cut
  off by a shutdown is marked failed at the next start.
- A folder destination writes database/minstrel-<UTC stamp>.tar and refuses
  a folder that isn't there. An S3 destination stages the file, hashes it,
  and uploads it under database/ through a throttled reader (4 MiB/s by
  default, 0 for no limit).
- Test destination checks the form as typed and only lists.
- The bucket secret is sealed like every stored credential; backup_settings
  and backup_runs are left out of the backup itself.
- The panel polls only while a run is going, capped at the run limit plus
  five minutes.

The music switch named in the task is left to #6774, so no control ships
that does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:50:55 -05:00
co-authored by Claude Opus 5.5
parent faf19ef67b
commit 9d324ab2d0
29 changed files with 2384 additions and 4 deletions
+69
View File
@@ -1086,3 +1086,72 @@ export async function clearAcquisitionSecret(
): Promise<void> {
await api.del(`${acquisitionBase}/${encodeURIComponent(code)}/${which}`);
}
// The server's own backup ---------------------------------------------------
export type BackupDestination = 'folder' | 's3';
export type BackupConfig = {
enabled: boolean;
destination: BackupDestination;
folder: string;
endpoint: string;
bucket: string;
access_key: string;
/** The secret key itself never comes back. */
has_secret_key: boolean;
/** The hour of day the nightly backup starts, in UTC. */
run_hour: number;
/** KiB a second an upload to the bucket may use; 0 is no limit. */
upload_limit_kib: number;
};
export type BackupState = 'off' | 'incomplete' | 'failing' | 'waiting' | 'overdue' | 'ok';
export type BackupRun = {
id: string;
trigger: 'schedule' | 'manual';
status: 'running' | 'succeeded' | 'failed';
started_at: string;
finished_at: string | null;
/** The file's path in the folder, or its key in the bucket. */
location: string;
bytes: number;
error: string;
};
export type BackupStatus = {
config: BackupConfig;
/** Worked out by the server; the panel only words it. */
state: BackupState;
problems: string[];
running_since: string | null;
last_success: string | null;
failing_since: string | null;
next_run: string | null;
/** The longest a run may take; the panel stops polling a little after. */
run_limit_seconds: number;
runs: BackupRun[];
};
/** A save or test. An empty secret_key keeps the stored one. */
export type BackupUpdate = Omit<BackupConfig, 'has_secret_key'> & { secret_key: string };
export type BackupTestResult = { backups: number; latest: string | null };
export async function getBackups(): Promise<BackupStatus> {
return api.get<BackupStatus>('/api/admin/backups');
}
export async function saveBackups(u: BackupUpdate): Promise<BackupStatus> {
return api.put<BackupStatus>('/api/admin/backups', u);
}
/** Checks the destination in u without saving it; nothing is written there. */
export async function testBackups(u: BackupUpdate): Promise<BackupTestResult> {
return api.post<BackupTestResult>('/api/admin/backups/test', u);
}
export async function runBackup(): Promise<void> {
await api.post<null>('/api/admin/backups/run', {});
}
+5 -1
View File
@@ -23,7 +23,11 @@ const DETAIL_CODES: ReadonlySet<string> = new Set([
// The server says what shape of address it wants.
'invalid_public_url',
// The server names the folder and what is wrong with it.
'bad_folders'
'bad_folders',
// The server says which backup setting and why.
'invalid_backup_setting',
// The destination's own answer: the bucket's refusal, or the missing folder.
'destination_failed'
]);
/**
@@ -0,0 +1,183 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { getBackups, runBackup, type BackupStatus } from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { formatBytes } from '#lib/utils/bytes.js';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import BackupSettings from './BackupSettings.svelte';
import { when } from './backupTime.js';
// The server's own backup: a verdict line first, then what to do about it,
// then the latest runs, with the settings beside them. The server works out
// the state; this only words it.
let status = $state<BackupStatus | null>(null);
let loadError = $state(false);
let starting = $state(false);
async function load() {
try {
status = await getBackups();
loadError = false;
} catch {
loadError = true;
}
}
onMount(load);
// Polled only while a backup runs, and never past the server's own limit
// on a run plus a margin, so a stuck run can't keep a tab asking forever.
const POLL_MS = 3000;
const POLL_MARGIN_MS = 5 * 60 * 1000;
let poll: ReturnType<typeof setTimeout> | null = null;
let pollStarted = 0;
function stopPolling() {
if (poll !== null) clearTimeout(poll);
poll = null;
}
function pollWhileRunning() {
stopPolling();
const limit = (status?.run_limit_seconds ?? 1800) * 1000 + POLL_MARGIN_MS;
if (!status?.running_since || Date.now() - pollStarted > limit) return;
poll = setTimeout(async () => {
try {
await load();
} finally {
pollWhileRunning();
}
}, POLL_MS);
}
$effect(() => {
if (status?.running_since && poll === null) {
pollStarted = Date.now();
pollWhileRunning();
}
});
onDestroy(stopPolling);
async function backUpNow() {
starting = true;
try {
await runBackup();
pushToast('Backup started.');
await load();
} catch (e) {
pushToast(errMessage(e), 'error');
await load();
} finally {
starting = false;
}
}
type Tone = 'good' | 'warn' | 'bad' | 'plain';
const toneClass: Record<Tone, string> = {
good: 'text-success-fg',
warn: 'text-warning-fg',
bad: 'text-error-fg',
plain: 'text-text-primary'
};
const verdict = $derived.by((): { tone: Tone; text: string } | null => {
const s = status;
if (!s) return null;
if (s.running_since) return { tone: 'plain', text: `Backing up, since ${when(s.running_since)}.` };
switch (s.state) {
case 'off':
return { tone: 'plain', text: 'Off. Nothing is backed up on a schedule.' };
case 'incomplete':
return { tone: 'warn', text: 'Switched on, but not set up yet.' };
case 'waiting':
return { tone: 'plain', text: 'Set up. No backup has been made yet.' };
case 'ok':
return { tone: 'good', text: `Backed up ${when(s.last_success!)}.` };
case 'overdue':
return { tone: 'warn', text: `No backup since ${when(s.last_success!)}.` };
case 'failing':
return { tone: 'bad', text: `Backups failing since ${when(s.failing_since!)}.` };
}
});
const latest = $derived(status?.runs[0]);
const ready = $derived(!!status && status.problems.length === 0);
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
</script>
{#if loadError && !status}
<p class="text-sm text-error-fg">
Couldn't load the backup status.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if !status}
<p class="text-sm text-text-secondary" aria-busy="true">Loading the backup status…</p>
{:else}
<SettingsColumns>
{#snippet main()}
<SettingsGroup title="Backup" id="backup" hint="Your accounts, likes, playlists, history and settings, in one file a night.">
<div class="space-y-1 px-4 py-3" role="status" data-testid="backup-verdict">
<p class="text-sm {toneClass[verdict?.tone ?? 'plain']}">{verdict?.text}</p>
{#if status.state === 'failing' && latest?.error && !status.running_since}
<p class="text-xs text-text-secondary">{latest.error}</p>
{/if}
{#if status.config.enabled}
{#each status.problems as problem (problem)}
<p class="text-xs text-text-secondary">{problem}</p>
{/each}
{/if}
{#if status.next_run && !status.running_since}
<p class="text-xs text-text-secondary">Next backup {when(status.next_run)}.</p>
{/if}
</div>
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
<button
type="button"
class={button}
disabled={starting || !!status.running_since || !ready}
title={ready ? undefined : 'Set up where backups go first'}
onclick={backUpNow}
>
{status.running_since ? 'Backing up…' : 'Back up now'}
</button>
</div>
</SettingsGroup>
{#if status.runs.length > 0}
<SettingsGroup title="Latest backups" id="backup-runs">
<ul class="divide-y divide-border text-sm" aria-label="Latest backups">
{#each status.runs as run (run.id)}
<li class="flex items-start justify-between gap-4 px-4 py-2">
<span class="min-w-0">
<span class="block text-text-primary">{when(run.started_at)}</span>
<span class="block break-all text-xs text-text-secondary">
{run.trigger === 'manual' ? 'Started by hand' : 'Scheduled'}
{#if run.status === 'succeeded'}· {run.location} · {formatBytes(run.bytes)}{/if}
{#if run.status === 'failed'}· {run.error}{/if}
</span>
</span>
<span
class="shrink-0 text-xs {run.status === 'succeeded'
? 'text-success-fg'
: run.status === 'failed'
? 'text-error-fg'
: 'text-text-secondary'}"
>
{run.status === 'succeeded' ? 'Saved' : run.status === 'failed' ? 'Failed' : 'Running'}
</span>
</li>
{/each}
</ul>
</SettingsGroup>
{/if}
{/snippet}
{#snippet side()}
<BackupSettings config={status!.config} onsaved={(s) => (status = s)} />
{/snippet}
</SettingsColumns>
{/if}
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { BackupStatus } from '#lib/api/admin.js';
vi.mock('#lib/api/admin.js', () => ({
getBackups: vi.fn(),
saveBackups: vi.fn(),
testBackups: vi.fn(),
runBackup: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import BackupPanel from './BackupPanel.svelte';
import { getBackups, runBackup, saveBackups, testBackups } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: BackupStatus = {
config: {
enabled: true,
destination: 's3',
folder: '',
endpoint: 'https://s3.example.com',
bucket: 'minstrel',
access_key: 'ak',
has_secret_key: true,
run_hour: 3,
upload_limit_kib: 4096
},
state: 'ok',
problems: [],
running_since: null,
last_success: '2026-11-02T03:00:00Z',
failing_since: null,
next_run: '2026-11-03T03:00:00Z',
run_limit_seconds: 1800,
runs: [
{
id: 'r1',
trigger: 'schedule',
status: 'succeeded',
started_at: '2026-11-02T03:00:00Z',
finished_at: '2026-11-02T03:00:09Z',
location: 'database/minstrel-20261102T030000Z.tar',
bytes: 12_000_000,
error: ''
}
]
};
function status(over: Partial<BackupStatus> = {}): BackupStatus {
return { ...base, ...over, config: { ...base.config, ...over.config } };
}
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
async function renderPanel(s: BackupStatus = base) {
vi.mocked(getBackups).mockResolvedValue(s);
render(BackupPanel);
await screen.findByTestId('backup-verdict');
}
const verdict = () => screen.getByTestId('backup-verdict').textContent?.replace(/\s+/g, ' ') ?? '';
describe('BackupPanel', () => {
test('leads with the verdict and lists the latest runs', async () => {
await renderPanel();
expect(verdict()).toMatch(/^ ?Backed up /);
expect(verdict()).toMatch(/Next backup /);
const runs = screen.getByRole('list', { name: /latest backups/i });
expect(runs.textContent).toMatch(/Scheduled · database\/minstrel-20261102T030000Z\.tar · 12 MB/);
expect(runs.textContent).toMatch(/Saved/);
});
test('a failing backup says since when and why', async () => {
await renderPanel(
status({
state: 'failing',
failing_since: '2026-11-02T03:00:00Z',
runs: [{ ...base.runs[0], status: 'failed', error: 'HTTP 403 SignatureDoesNotMatch: the secret key is wrong' }]
})
);
expect(verdict()).toMatch(/Backups failing since /);
expect(verdict()).toMatch(/the secret key is wrong/);
});
test('switched on but not set up: names what is missing and holds Back up now', async () => {
await renderPanel(status({ state: 'incomplete', problems: ['Enter the secret key.'], next_run: null }));
expect(verdict()).toMatch(/not set up yet/);
expect(verdict()).toMatch(/Enter the secret key\./);
expect(screen.getByRole('button', { name: /back up now/i })).toHaveProperty('disabled', true);
});
test('switched off reads as off, without nagging about the destination', async () => {
await renderPanel(status({ state: 'off', config: { ...base.config, enabled: false }, problems: ['Enter the secret key.'] }));
expect(verdict()).toMatch(/^ ?Off\./);
expect(verdict()).not.toMatch(/secret key/);
});
test('Back up now starts a run and polls only while it runs', async () => {
vi.useFakeTimers();
vi.mocked(runBackup).mockResolvedValue();
const running = status({ running_since: '2026-11-02T10:00:00Z' });
vi.mocked(getBackups).mockResolvedValueOnce(base).mockResolvedValueOnce(running).mockResolvedValueOnce(running).mockResolvedValue(base);
render(BackupPanel);
await vi.waitFor(() => screen.getByTestId('backup-verdict'));
await fireEvent.click(screen.getByRole('button', { name: /back up now/i }));
await vi.waitFor(() => expect(runBackup).toHaveBeenCalled());
await vi.waitFor(() => expect(verdict()).toMatch(/Backing up, since /));
expect(pushToast).toHaveBeenCalledWith('Backup started.');
await vi.advanceTimersByTimeAsync(3000);
await vi.advanceTimersByTimeAsync(3000);
await vi.waitFor(() => expect(verdict()).toMatch(/Backed up /));
const calls = vi.mocked(getBackups).mock.calls.length;
await vi.advanceTimersByTimeAsync(30_000);
expect(vi.mocked(getBackups).mock.calls.length).toBe(calls);
});
test('a stuck run stops being polled after the run limit and a margin', async () => {
vi.useFakeTimers();
vi.mocked(getBackups).mockResolvedValue(status({ running_since: '2026-11-02T10:00:00Z', run_limit_seconds: 60 }));
render(BackupPanel);
await vi.waitFor(() => screen.getByTestId('backup-verdict'));
// 60 s limit + 5 min margin, and a little more.
await vi.advanceTimersByTimeAsync(7 * 60 * 1000);
const calls = vi.mocked(getBackups).mock.calls.length;
await vi.advanceTimersByTimeAsync(60_000);
expect(vi.mocked(getBackups).mock.calls.length).toBe(calls);
});
test('never shows the stored secret key, and sends one only when typed', async () => {
vi.mocked(saveBackups).mockResolvedValue(base);
await renderPanel();
const key = screen.getByLabelText(/secret key/i) as HTMLInputElement;
expect(key.value).toBe('');
expect(key.placeholder).toBe('Key saved');
const save = screen.getByRole('button', { name: /^save$/i });
expect(save).toHaveProperty('disabled', true);
await fireEvent.input(screen.getByLabelText(/^bucket$/i), { target: { value: 'music-backup' } });
await fireEvent.click(save);
await waitFor(() =>
expect(saveBackups).toHaveBeenCalledWith({
enabled: true,
destination: 's3',
folder: '',
endpoint: 'https://s3.example.com',
bucket: 'music-backup',
access_key: 'ak',
secret_key: '',
run_hour: 3,
upload_limit_kib: 4096
})
);
});
test('Test destination checks the form as typed and says what it found', async () => {
vi.mocked(testBackups).mockResolvedValue({ backups: 3, latest: '2026-11-02T03:00:05Z' });
await renderPanel();
await fireEvent.click(screen.getByRole('button', { name: /test destination/i }));
const out = await screen.findByTestId('backup-test');
expect(out.textContent).toMatch(/Reached it\.\s+3 backups there, the newest from /);
});
test('a refused test shows the destination’s answer', async () => {
vi.mocked(testBackups).mockRejectedValue({
code: 'destination_failed',
message: 'HTTP 404 NoSuchBucket: there is no bucket by that name'
});
await renderPanel();
await fireEvent.click(screen.getByRole('button', { name: /test destination/i }));
expect((await screen.findByTestId('backup-test')).textContent).toMatch(
/Couldn't reach the backup destination\. HTTP 404 NoSuchBucket/
);
});
test('the folder destination asks for a path, not a bucket', async () => {
await renderPanel(status({ config: { ...base.config, destination: 'folder', folder: '/backups' } }));
expect(screen.getByLabelText(/^folder$/i)).toHaveProperty('value', '/backups');
expect(screen.queryByLabelText(/^bucket$/i)).toBeNull();
});
});
@@ -0,0 +1,183 @@
<script lang="ts">
import {
saveBackups,
testBackups,
type BackupConfig,
type BackupDestination,
type BackupStatus,
type BackupTestResult,
type BackupUpdate
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import Segmented from '#lib/components/settings/Segmented.svelte';
import { hourLabel, when } from './backupTime.js';
// Where the nightly backup goes and when. The form keeps its own drafts,
// filled from the server on load and after each save, so a status poll
// never overwrites what is being typed.
let { config, onsaved }: { config: BackupConfig; onsaved: (s: BackupStatus) => void } = $props();
// svelte-ignore state_referenced_locally
let draft = $state(fromConfig(config));
// Empty means "keep the stored key". The key itself never comes back.
let secretKey = $state('');
let saving = $state(false);
let testing = $state(false);
let check = $state<BackupTestResult | null>(null);
let testError = $state('');
function fromConfig(c: BackupConfig) {
return {
enabled: c.enabled,
destination: c.destination,
folder: c.folder,
endpoint: c.endpoint,
bucket: c.bucket,
access_key: c.access_key,
run_hour: c.run_hour,
upload_limit_kib: c.upload_limit_kib
};
}
const update = (): BackupUpdate => ({ ...draft, secret_key: secretKey.trim() });
const dirty = $derived(
secretKey.trim() !== '' || JSON.stringify(draft) !== JSON.stringify(fromConfig(config))
);
async function save() {
saving = true;
try {
const s = await saveBackups(update());
secretKey = '';
draft = fromConfig(s.config);
check = null;
onsaved(s);
pushToast('Backup settings saved.');
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
async function test() {
testing = true;
check = null;
testError = '';
try {
check = await testBackups(update());
} catch (e) {
testError = errMessage(e);
} finally {
testing = false;
}
}
const destinations: { value: BackupDestination; label: string }[] = [
{ value: 's3', label: 'S3 bucket' },
{ value: 'folder', label: 'Folder' }
];
const hours = Array.from({ length: 24 }, (_, h) => h);
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const field = 'rounded-md border border-border bg-background px-3 py-1.5 text-sm';
</script>
<SettingsGroup
title="Backup settings"
id="backup-settings"
hint="Minstrel only ever adds backups. Thin out old ones with the destination's own retention."
>
<SettingRow label="Back up every night" for="backup-enabled" hint="Off until you switch it on.">
<input
id="backup-enabled"
type="checkbox"
role="switch"
aria-checked={draft.enabled}
bind:checked={draft.enabled}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
<SettingRow label="Destination">
<Segmented
name="backup-destination"
label="Destination"
options={destinations}
value={draft.destination}
onchange={(v) => (draft.destination = v)}
/>
</SettingRow>
{#if draft.destination === 'folder'}
<SettingRow label="Folder" for="backup-folder" hint="A full path on the server, such as a mounted volume.">
<input id="backup-folder" bind:value={draft.folder} placeholder="/backups" autocomplete="off" spellcheck="false" class="{field} w-56 font-mono" />
</SettingRow>
{:else}
<SettingRow label="Endpoint" for="backup-endpoint" hint="The bucket's server, path-style.">
<input
id="backup-endpoint"
bind:value={draft.endpoint}
placeholder="https://s3.example.com"
autocomplete="off"
spellcheck="false"
class="{field} w-56"
/>
</SettingRow>
<SettingRow label="Bucket" for="backup-bucket">
<input id="backup-bucket" bind:value={draft.bucket} placeholder="minstrel" autocomplete="off" spellcheck="false" class="{field} w-56" />
</SettingRow>
<SettingRow label="Access key" for="backup-access-key">
<input id="backup-access-key" bind:value={draft.access_key} autocomplete="off" spellcheck="false" class="{field} w-56 font-mono" />
</SettingRow>
<SettingRow label="Secret key" for="backup-secret-key" hint="Kept encrypted on the server.">
<input
id="backup-secret-key"
type="password"
autocomplete="new-password"
bind:value={secretKey}
placeholder={config.has_secret_key ? 'Key saved' : 'Paste key'}
class="{field} w-56 font-mono"
/>
</SettingRow>
<SettingRow label="Upload limit" for="backup-limit" hint="KiB a second, so an upload leaves room on the connection. 0 is no limit.">
<input id="backup-limit" type="number" min="0" step="256" bind:value={draft.upload_limit_kib} class="{field} w-28" />
</SettingRow>
{/if}
<SettingRow label="Time of day" for="backup-hour" hint="On your clock.">
<select id="backup-hour" bind:value={draft.run_hour} class={field}>
{#each hours as h (h)}
<option value={h}>{hourLabel(h)}</option>
{/each}
</select>
</SettingRow>
<div class="space-y-2 px-4 py-3">
<div class="flex flex-wrap items-center justify-end gap-2">
<button type="button" class={button} disabled={testing} onclick={test}>
{testing ? 'Testing…' : 'Test destination'}
</button>
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{#if testError}
<p class="text-sm text-error-fg" role="alert" data-testid="backup-test">{testError}</p>
{:else if check}
<p class="text-sm text-success-fg" role="status" data-testid="backup-test">
Reached it.
{#if check.backups === 0}
No backups there yet.
{:else}
{check.backups} {check.backups === 1 ? 'backup' : 'backups'} there, the newest from {when(check.latest!)}.
{/if}
</p>
{/if}
</div>
</SettingsGroup>
@@ -0,0 +1,14 @@
/** An exact moment in the viewer's own zone, with its date. */
export function when(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
/**
* The server keeps the run hour in UTC; this is that hour on the viewer's
* clock today. Today's offset is used, so across daylight saving the local
* label moves while the run stays at the same UTC hour.
*/
export function hourLabel(utcHour: number, now: Date = new Date()): string {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), utcHour));
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
+6 -1
View File
@@ -72,5 +72,10 @@
"block_not_found": "That release isn't set aside any more.",
"not_running": "That download isn't running any more. Refresh the list.",
"bad_lifecycle": "Seed ratio is 0 to 100, seed days 0 to 365, and stall hours 0 to 720.",
"bad_upgrade_until": "Upgrade until has to be one of the qualities listed."
"bad_upgrade_until": "Upgrade until has to be one of the qualities listed.",
"invalid_backup_setting": "That backup setting can't be saved.",
"destination_failed": "Couldn't reach the backup destination.",
"backup_running": "A backup is already running.",
"backup_not_ready": "Set up where backups go first.",
"shutting_down": "The server is shutting down. Try again in a moment."
}
+1
View File
@@ -34,6 +34,7 @@
{ href: '/admin/metadata', label: 'Metadata sources' },
{ href: '/admin/email', label: 'Email & network' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/backups', label: 'Backups' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' }
]);
+1
View File
@@ -291,6 +291,7 @@ describe('admin layout', () => {
['/admin/metadata', 'Metadata sources'],
['/admin/email', 'Email & network'],
['/admin/users', 'Users'],
['/admin/backups', 'Backups'],
['/admin/diagnostics', 'Diagnostics'],
['/admin/tuning', 'Tuning']
]);
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import BackupPanel from '#lib/components/admin/BackupPanel.svelte';
// Admin › Backups: Minstrel's own nightly backup, where it goes, and how
// the latest runs went.
</script>
<svelte:head><title>{pageTitle('Admin · Backups')}</title></svelte:head>
<h2 class="sr-only">Backups</h2>
<BackupPanel />