From 4161863587fda7cb926b8df5c2788c0c4ebf192d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 08:34:29 +0100 Subject: [PATCH] Run whole-data archives and restores in the background A data archive or restore ran inside its request, which sent nothing until tar finished: Bun closes a silent socket after 10s (its default idleTimeout in production) and a proxy cuts it at about 100s, so the page showed a network error while the archive kept going, and a retry said another backup was already running. The routes now start the run (startDataBackupRun, refused while the backup lock is held) and answer at once. The page follows it by polling api/admin/backups/data-status: an archive reloads once written, a restore once the status comes from a new process after its restart, and a failure shows its error. A run in progress survives a reload. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/data/app-data.ts | 7 ++ .../backend/backups/data-backup-run.test.ts | 83 +++++++++++++ .../backend/backups/data-backup-run.ts | 66 +++++++++++ .../backups/save-uploaded-data-backup.ts | 5 +- .../backend/backups/with-backup-run-lock.ts | 8 ++ .../backups/(data)/data-backup-run-context.ts | 26 +++++ .../backups/(functions)/create-data-backup.ts | 6 + .../(functions)/grab-backups-server-props.ts | 5 +- .../(functions)/restore-data-backup.ts | 9 +- .../(functions)/upload-restore-data-backup.ts | 8 +- .../backups/(hooks)/use-data-backup-run.ts | 98 ++++++++++++++++ .../(partials)/data-backup-list-actions.tsx | 28 +++-- .../(partials)/data-backup-row-actions.tsx | 42 +++---- .../(partials)/upload-restore-fields.tsx | 20 +++- .../(partials)/upload-restore-modal.tsx | 25 ++-- .../(sections)/data-backups-section.tsx | 45 ++++--- src/pages/api/admin/backups/create-data.ts | 66 +++++++---- src/pages/api/admin/backups/data-status.ts | 43 +++++++ src/pages/api/admin/backups/restore-data.ts | 80 +++++++------ .../api/admin/backups/upload-restore-data.ts | 110 ++++++++++++------ src/types/index.ts | 27 +++++ 22 files changed, 630 insertions(+), 179 deletions(-) create mode 100644 src/functions/backend/backups/data-backup-run.test.ts create mode 100644 src/functions/backend/backups/data-backup-run.ts create mode 100644 src/pages/admin/settings/backups/(data)/data-backup-run-context.ts create mode 100644 src/pages/admin/settings/backups/(hooks)/use-data-backup-run.ts create mode 100644 src/pages/api/admin/backups/data-status.ts diff --git a/AGENTS.md b/AGENTS.md index 63ccfa7..f286954 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,7 +229,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/logs` (live) - Activity, the audit log: super admins only (others are redirected; the popover's "Activity" item shows to super admins only). Rendered in `SettingsShell` with `wide` - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other -- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import `, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer workspaces, bot and workspace files, MCP installs; an older computer's moved-aside `computers/-home-` is left out) into `BACKUP_ARCHIVE_DIR` as `cobble-data-.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` +- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import `, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer workspaces, bot and workspace files, MCP installs; an older computer's moved-aside `computers/-home-` is left out) into `BACKUP_ARCHIVE_DIR` as `cobble-data-.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. A whole-data archive or restore takes far longer than a request may stay silent (Bun's default `idleTimeout` is 10s, a proxy cuts at about 100s), so its route only starts it (`startDataBackupRun`, `data-backup-run.ts`, which refuses while anything holds the lock) and answers with the run; the page follows it (`use-data-backup-run`, `DataBackupRunContext`) by polling `api/admin/backups/data-status`, reloading once an archive is done, and after a restore once the status comes from a new process (`boot_id`). The upload is still saved within its request. Database snapshots stay synchronous. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` - `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/` - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 990f4e8..901bf2c 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -607,6 +607,13 @@ export const AppData = { */ DataBackupNamePrefix: "cobble-data-", DataBackupsKept: 5, + /** + * How often the backups page asks how a whole-data archive or restore + * is going, and how long it waits for the app to come back after a + * restore restarted it before it reloads anyway. + */ + DataBackupPollMs: 2000, + DataRestoreWaitMs: 120000, /** * Responses smaller than this go out uncompressed: the encoding * costs more than it saves. diff --git a/src/functions/backend/backups/data-backup-run.test.ts b/src/functions/backend/backups/data-backup-run.test.ts new file mode 100644 index 0000000..2ae04c2 --- /dev/null +++ b/src/functions/backend/backups/data-backup-run.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import AppError from "@/src/utils/app-error"; +import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run"; +import { isBackupRunning } from "./with-backup-run-lock"; + +function deferred() { + let resolve!: (name: string) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("startDataBackupRun", () => { + test("answers at once, then records the finished archive", async () => { + const task = deferred(); + + const run = startDataBackupRun({ + kind: "archive", + task: () => task.promise, + }); + + expect(run.status).toBe("running"); + expect(run.kind).toBe("archive"); + expect(run.boot_id).toBe(BootId); + expect(isBackupRunning()).toBe(true); + + task.resolve("cobble-data-1.tar.gz"); + await settle(); + + const done = grabDataBackupRun(); + expect(done.status).toBe("done"); + expect(done.name).toBe("cobble-data-1.tar.gz"); + expect(isBackupRunning()).toBe(false); + }); + + test("refuses a second run while one is going, before it starts", async () => { + const task = deferred(); + let second_started = false; + + startDataBackupRun({ kind: "archive", task: () => task.promise }); + + expect(() => + startDataBackupRun({ + kind: "restore", + task: async () => { + second_started = true; + return "x"; + }, + }), + ).toThrow(AppError); + expect(second_started).toBe(false); + + task.resolve("cobble-data-2.tar.gz"); + await settle(); + }); + + test("records a failure and frees the lock for the next run", async () => { + const task = deferred(); + + startDataBackupRun({ kind: "restore", task: () => task.promise }); + task.reject(new Error("tar: broken")); + await settle(); + + const failed = grabDataBackupRun(); + expect(failed.status).toBe("failed"); + expect(failed.kind).toBe("restore"); + expect(failed.error).toBe("tar: broken"); + expect(isBackupRunning()).toBe(false); + + const next = startDataBackupRun({ + kind: "archive", + task: async () => "cobble-data-3.tar.gz", + }); + expect(next.status).toBe("running"); + await settle(); + expect(grabDataBackupRun().status).toBe("done"); + }); +}); diff --git a/src/functions/backend/backups/data-backup-run.ts b/src/functions/backend/backups/data-backup-run.ts new file mode 100644 index 0000000..37b62d5 --- /dev/null +++ b/src/functions/backend/backups/data-backup-run.ts @@ -0,0 +1,66 @@ +import AppError from "@/src/utils/app-error"; +import type { DataBackupRun, DataBackupRunKind } from "@/src/types"; +import withBackupRunLock, { isBackupRunning } from "./with-backup-run-lock"; + +/** + * Changes every time the process starts, so the page can tell a restore + * restarted the app (the run it started belonged to another boot). + */ +export const BootId = crypto.randomUUID(); + +let run: Omit | null = null; + +type Params = { + kind: DataBackupRunKind; + /** + * The archive or restore itself. It resolves to the archive's name. + */ + task: () => Promise; +}; + +/** + * Starts a whole-data archive or restore in the background and hands back + * its state at once. Both take far longer than a request may stay silent + * (Bun closes an idle socket after 10s, a proxy after about 100s), so the + * route answers straight away and the page polls `data-status` until the + * run is done or failed. Throws while any backup or restore is running, + * before anything starts. + */ +export function startDataBackupRun({ kind, task }: Params): DataBackupRun { + if (isBackupRunning() || run?.status == "running") { + throw new AppError(`Another backup or restore is already running`); + } + + const started = { + kind, + status: "running" as const, + started_at: Date.now(), + }; + + run = started; + + withBackupRunLock({ task }) + .then((name) => { + if (run !== started) return; + run = { ...started, status: "done", name, finished_at: Date.now() }; + }) + .catch((error: unknown) => { + if (run !== started) return; + run = { + ...started, + status: "failed", + error: error instanceof Error ? error.message : String(error), + finished_at: Date.now(), + }; + }); + + return grabDataBackupRun(); +} + +/** + * The last whole-data run this process started, if any, with this boot's + * id. + */ +export function grabDataBackupRun(): DataBackupRun { + return { ...(run ?? { status: "idle" }), boot_id: BootId }; +} diff --git a/src/functions/backend/backups/save-uploaded-data-backup.ts b/src/functions/backend/backups/save-uploaded-data-backup.ts index 903a5f0..4d34f56 100644 --- a/src/functions/backend/backups/save-uploaded-data-backup.ts +++ b/src/functions/backend/backups/save-uploaded-data-backup.ts @@ -2,6 +2,7 @@ import fs from "fs"; import path from "path"; import { AppData } from "@/src/data/app-data"; import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir"; +import AppError from "@/src/utils/app-error"; type Params = { file: File; @@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b]; */ export default async function saveUploadedDataBackup({ file }: Params) { if (!file.size) { - throw new Error(`The uploaded archive is empty`); + throw new AppError(`The uploaded archive is empty`); } const head = new Uint8Array(await file.slice(0, 2).arrayBuffer()); if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) { - throw new Error(`Only a gzipped tar archive can be restored`); + throw new AppError(`Only a gzipped tar archive can be restored`); } const archive_dir = grabBackupArchiveDir(); diff --git a/src/functions/backend/backups/with-backup-run-lock.ts b/src/functions/backend/backups/with-backup-run-lock.ts index 48b7475..6c19338 100644 --- a/src/functions/backend/backups/with-backup-run-lock.ts +++ b/src/functions/backend/backups/with-backup-run-lock.ts @@ -4,6 +4,14 @@ type Params = { task: () => Promise; }; +/** + * Whether a backup or restore of any kind holds the lock right now, so a + * background run can be refused before it starts. + */ +export function isBackupRunning() { + return is_running; +} + /** * Runs one backup or restore at a time, whichever kind it is, so a * database snapshot, a database restore and a whole-data archive never diff --git a/src/pages/admin/settings/backups/(data)/data-backup-run-context.ts b/src/pages/admin/settings/backups/(data)/data-backup-run-context.ts new file mode 100644 index 0000000..bbd9c6b --- /dev/null +++ b/src/pages/admin/settings/backups/(data)/data-backup-run-context.ts @@ -0,0 +1,26 @@ +import React from "react"; +import type { DataBackupRun } from "@/src/types"; + +export type DataBackupRunState = { + /** + * The archive or restore running now, while it runs. + */ + run?: DataBackupRun; + /** + * Why the last run failed. + */ + error?: string; + /** + * Follows a run a request just started until it ends. + */ + track: (run: DataBackupRun) => void; +}; + +/** + * The whole-data run the backups page is waiting on, shared by the list's + * actions, every archive row and the upload modal, so none of them starts + * another while one is going. + */ +export const DataBackupRunContext = React.createContext({ + track: () => {}, +}); diff --git a/src/pages/admin/settings/backups/(functions)/create-data-backup.ts b/src/pages/admin/settings/backups/(functions)/create-data-backup.ts index 95186d6..b43213d 100644 --- a/src/pages/admin/settings/backups/(functions)/create-data-backup.ts +++ b/src/pages/admin/settings/backups/(functions)/create-data-backup.ts @@ -1,5 +1,10 @@ import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler"; +import type { DataBackupRun } from "@/src/types"; +/** + * Starts the archive, which runs in the background on the server, and + * hands back its run for the page to follow. + */ export default async function createDataBackup() { if ( !window.confirm( @@ -17,5 +22,6 @@ export default async function createDataBackup() { return { success: Boolean(res.success), msg: res.success ? undefined : res.msg || `Couldn't create archive`, + run: res.success ? (res.singleRes as DataBackupRun | undefined) : undefined, }; } diff --git a/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts b/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts index 6da47b3..33dedc0 100644 --- a/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts +++ b/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts @@ -1,6 +1,7 @@ import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups"; import grabDataBackups from "@/src/functions/backend/backups/grab-data-backups"; +import { grabDataBackupRun } from "@/src/functions/backend/backups/data-backup-run"; import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict"; import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; @@ -10,7 +11,8 @@ type Params = Parameters>[0]; /** * Shared server props for the `/admin/settings/backups` sub-pages: the - * saved database snapshots and the whole-data archives. Super admins + * saved database snapshots, the whole-data archives and the archive or + * restore running now, so a reload mid-run picks it back up. Super admins * only; everyone else lands on the general settings. */ export default async function grabBackupsServerProps(params: Params) { @@ -32,6 +34,7 @@ export default async function grabBackupsServerProps(params: Params) { ...settings.props, backups: grabDbBackups(), data_backups: grabDataBackups(), + data_backup_run: grabDataBackupRun(), }, }; } diff --git a/src/pages/admin/settings/backups/(functions)/restore-data-backup.ts b/src/pages/admin/settings/backups/(functions)/restore-data-backup.ts index dae9328..0884b96 100644 --- a/src/pages/admin/settings/backups/(functions)/restore-data-backup.ts +++ b/src/pages/admin/settings/backups/(functions)/restore-data-backup.ts @@ -1,5 +1,5 @@ import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler"; -import type { DataBackup } from "@/src/types"; +import type { DataBackup, DataBackupRun } from "@/src/types"; import dayjs from "dayjs"; type Params = { @@ -7,9 +7,9 @@ type Params = { }; /** - * Replaces the whole data volume with the archive. The server takes a - * safety archive first and restarts afterwards, so this only sends the - * request and leaves the reload to the button. + * Starts replacing the whole data volume with the archive. The server + * takes a safety archive first and restarts afterwards, all in the + * background, so this hands back the run for the page to follow. */ export default async function restoreDataBackup({ backup }: Params) { const confirm_msg = `Restore the data volume from the archive of ${dayjs(backup.created_at).format("MMM D, YYYY HH:mm:ss")}?\n\nEvery bot, workspace, computer home, file, media item and database snapshot is replaced by the archive's, and the app restarts. A safety archive of the current data is made first.`; @@ -29,5 +29,6 @@ export default async function restoreDataBackup({ backup }: Params) { return { success: Boolean(res.success), msg: res.success ? undefined : res.msg || `Couldn't restore archive`, + run: res.success ? (res.singleRes as DataBackupRun | undefined) : undefined, }; } diff --git a/src/pages/admin/settings/backups/(functions)/upload-restore-data-backup.ts b/src/pages/admin/settings/backups/(functions)/upload-restore-data-backup.ts index 56653a5..5940a8f 100644 --- a/src/pages/admin/settings/backups/(functions)/upload-restore-data-backup.ts +++ b/src/pages/admin/settings/backups/(functions)/upload-restore-data-backup.ts @@ -1,11 +1,14 @@ +import type { DataBackupRun } from "@/src/types"; + type Params = { file: File; }; /** * Sends the file as multipart form data (not base64 JSON) so large archives - * aren't inflated. The app replaces the data volume and restarts, so the - * caller reloads the page once it is back. + * aren't inflated. Once the file is saved the server restores it in the + * background and restarts, so this hands back the run for the page to + * follow. */ export default async function uploadRestoreDataBackup({ file }: Params) { const confirm_msg = `Restore the data volume from "${file.name}"?\n\nEvery bot, workspace, computer home, file, media item and database snapshot is replaced by the archive's, and the app restarts. A safety archive of the current data is made first.`; @@ -28,6 +31,7 @@ export default async function uploadRestoreDataBackup({ file }: Params) { return { success: Boolean(json.success), msg: json.success ? undefined : json.msg || `Couldn't restore archive`, + run: json.success ? (json.singleRes as DataBackupRun | undefined) : undefined, }; } catch (error: any) { return { diff --git a/src/pages/admin/settings/backups/(hooks)/use-data-backup-run.ts b/src/pages/admin/settings/backups/(hooks)/use-data-backup-run.ts new file mode 100644 index 0000000..98876b6 --- /dev/null +++ b/src/pages/admin/settings/backups/(hooks)/use-data-backup-run.ts @@ -0,0 +1,98 @@ +import { useContext, useEffect, useState } from "react"; +import { AppContext } from "@/src/pages/__root"; +import { AppData } from "@/src/data/app-data"; +import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler"; +import type { DataBackupRun, DataBackupStatus } from "@/src/types"; +import type { DataBackupRunState } from "../(data)/data-backup-run-context"; + +/** + * Follows a whole-data archive or restore, which runs in the background + * on the server, by polling `data-status` every `AppData.DataBackupPollMs`. + * An archive reloads the page once it is written. A restore restarts the + * app, so failed polls are expected and the page reloads once the status + * comes from a new boot (or after `AppData.DataRestoreWaitMs` anyway). + * Picks a run already going back up from the page props. + */ +export default function useDataBackupRun(): DataBackupRunState { + const { pageProps } = useContext(AppContext); + const initial = pageProps?.data_backup_run; + const [run, setRun] = useState( + initial?.status == "running" ? initial : undefined, + ); + const [error, setError] = useState(); + + useEffect(() => { + if (run?.status != "running") return; + + const active = run; + + const deadline = Date.now() + AppData.DataRestoreWaitMs; + let timeout: ReturnType | undefined; + let stopped = false; + + const fail = (msg: string) => { + setError(msg); + setRun(undefined); + }; + + const poll = async () => { + const res = await adminFetchHandler({ + url: "/api/admin/backups/data-status", + }); + + if (stopped) return; + + const current = res.success + ? (res.singleRes as DataBackupStatus | undefined)?.run + : undefined; + const restarted = Boolean( + current && current.boot_id != active.boot_id, + ); + + if (active.kind == "restore") { + if (restarted || Date.now() > deadline) { + window.location.reload(); + return; + } + + if (current?.status == "failed") { + fail(current.error || `Couldn't restore archive`); + return; + } + } else if (current) { + if (current.status == "done") { + window.location.reload(); + return; + } + + if (current.status == "failed") { + fail(current.error || `Couldn't create archive`); + return; + } + + if (restarted) { + fail(`The app restarted before the archive was finished`); + return; + } + } + + timeout = setTimeout(poll, AppData.DataBackupPollMs); + }; + + timeout = setTimeout(poll, AppData.DataBackupPollMs); + + return () => { + stopped = true; + if (timeout) clearTimeout(timeout); + }; + }, [run]); + + return { + run, + error, + track: (next) => { + setError(undefined); + setRun(next); + }, + }; +} diff --git a/src/pages/admin/settings/backups/(partials)/data-backup-list-actions.tsx b/src/pages/admin/settings/backups/(partials)/data-backup-list-actions.tsx index 4c32349..3f41019 100644 --- a/src/pages/admin/settings/backups/(partials)/data-backup-list-actions.tsx +++ b/src/pages/admin/settings/backups/(partials)/data-backup-list-actions.tsx @@ -5,15 +5,20 @@ import Row from "@moduletrace/twui/layout/Row"; import useStatus from "@moduletrace/twui/hooks/useStatus"; import createDataBackup from "../(functions)/create-data-backup"; import UploadRestoreModal from "../(partials)/upload-restore-modal"; -import { useState } from "react"; +import { DataBackupRunContext } from "../(data)/data-backup-run-context"; +import { useContext, useState } from "react"; /** * The data archive list's two ways in: make a fresh archive, or restore - * one from a file. + * one from a file. Also says how the archive or restore running now is + * going, and why the last one failed. */ export default function DataBackupListActions() { const { loading, setLoading, status, setStatus } = useStatus(); + const { run, error, track } = useContext(DataBackupRunContext); const [uploading, setUploading] = useState(false); + const archiving = run?.kind == "archive"; + const restoring = run?.kind == "restore"; const create_backup = async () => { setStatus(undefined); @@ -21,13 +26,13 @@ export default function DataBackupListActions() { const res = await createDataBackup(); - if (res.success) { - window.location.reload(); + setLoading(false); + + if (res.run) { + track(res.run); return; } - setLoading(false); - if (res.msg) { setStatus({ error: true, msg: res.msg }); } @@ -36,11 +41,19 @@ export default function DataBackupListActions() { return ( {status?.error && {status.msg}} + {!status?.error && error && {error}} + {archiving && ( + Archiving, this can take a while... + )} + {restoring && ( + Restoring, the app restarts soon... + )}