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... + )}