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 <[email protected]>
This commit is contained in:
@@ -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/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)
|
- `/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
|
- 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 <file>`, 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/<id>-home-<ts>` is left out) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.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 <file>`, 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/<id>-home-<ts>` is left out) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.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/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
|
- `/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
|
||||||
|
|
||||||
|
|||||||
@@ -607,6 +607,13 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
DataBackupNamePrefix: "cobble-data-",
|
DataBackupNamePrefix: "cobble-data-",
|
||||||
DataBackupsKept: 5,
|
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
|
* Responses smaller than this go out uncompressed: the encoding
|
||||||
* costs more than it saves.
|
* costs more than it saves.
|
||||||
|
|||||||
@@ -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<string>((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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DataBackupRun, "boot_id"> | null = null;
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: DataBackupRunKind;
|
||||||
|
/**
|
||||||
|
* The archive or restore itself. It resolves to the archive's name.
|
||||||
|
*/
|
||||||
|
task: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b];
|
|||||||
*/
|
*/
|
||||||
export default async function saveUploadedDataBackup({ file }: Params) {
|
export default async function saveUploadedDataBackup({ file }: Params) {
|
||||||
if (!file.size) {
|
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());
|
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||||
|
|
||||||
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
|
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();
|
const archive_dir = grabBackupArchiveDir();
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ type Params<T> = {
|
|||||||
task: () => Promise<T>;
|
task: () => Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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
|
* database snapshot, a database restore and a whole-data archive never
|
||||||
|
|||||||
@@ -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<DataBackupRunState>({
|
||||||
|
track: () => {},
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
|
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() {
|
export default async function createDataBackup() {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
@@ -17,5 +22,6 @@ export default async function createDataBackup() {
|
|||||||
return {
|
return {
|
||||||
success: Boolean(res.success),
|
success: Boolean(res.success),
|
||||||
msg: res.success ? undefined : res.msg || `Couldn't create archive`,
|
msg: res.success ? undefined : res.msg || `Couldn't create archive`,
|
||||||
|
run: res.success ? (res.singleRes as DataBackupRun | undefined) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
|
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
|
||||||
import grabDataBackups from "@/src/functions/backend/backups/grab-data-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 { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||||
import type { PagePropsType } from "@/src/types";
|
import type { PagePropsType } from "@/src/types";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
@@ -10,7 +11,8 @@ type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared server props for the `/admin/settings/backups` sub-pages: the
|
* 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.
|
* only; everyone else lands on the general settings.
|
||||||
*/
|
*/
|
||||||
export default async function grabBackupsServerProps(params: Params) {
|
export default async function grabBackupsServerProps(params: Params) {
|
||||||
@@ -32,6 +34,7 @@ export default async function grabBackupsServerProps(params: Params) {
|
|||||||
...settings.props,
|
...settings.props,
|
||||||
backups: grabDbBackups(),
|
backups: grabDbBackups(),
|
||||||
data_backups: grabDataBackups(),
|
data_backups: grabDataBackups(),
|
||||||
|
data_backup_run: grabDataBackupRun(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
|
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";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -7,9 +7,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole data volume with the archive. The server takes a
|
* Starts replacing the whole data volume with the archive. The server
|
||||||
* safety archive first and restarts afterwards, so this only sends the
|
* takes a safety archive first and restarts afterwards, all in the
|
||||||
* request and leaves the reload to the button.
|
* background, so this hands back the run for the page to follow.
|
||||||
*/
|
*/
|
||||||
export default async function restoreDataBackup({ backup }: Params) {
|
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.`;
|
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 {
|
return {
|
||||||
success: Boolean(res.success),
|
success: Boolean(res.success),
|
||||||
msg: res.success ? undefined : res.msg || `Couldn't restore archive`,
|
msg: res.success ? undefined : res.msg || `Couldn't restore archive`,
|
||||||
|
run: res.success ? (res.singleRes as DataBackupRun | undefined) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import type { DataBackupRun } from "@/src/types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
file: File;
|
file: File;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends the file as multipart form data (not base64 JSON) so large archives
|
* 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
|
* aren't inflated. Once the file is saved the server restores it in the
|
||||||
* caller reloads the page once it is back.
|
* background and restarts, so this hands back the run for the page to
|
||||||
|
* follow.
|
||||||
*/
|
*/
|
||||||
export default async function uploadRestoreDataBackup({ file }: Params) {
|
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.`;
|
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 {
|
return {
|
||||||
success: Boolean(json.success),
|
success: Boolean(json.success),
|
||||||
msg: json.success ? undefined : json.msg || `Couldn't restore archive`,
|
msg: json.success ? undefined : json.msg || `Couldn't restore archive`,
|
||||||
|
run: json.success ? (json.singleRes as DataBackupRun | undefined) : undefined,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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<DataBackupRun | undefined>(
|
||||||
|
initial?.status == "running" ? initial : undefined,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (run?.status != "running") return;
|
||||||
|
|
||||||
|
const active = run;
|
||||||
|
|
||||||
|
const deadline = Date.now() + AppData.DataRestoreWaitMs;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,15 +5,20 @@ import Row from "@moduletrace/twui/layout/Row";
|
|||||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||||
import createDataBackup from "../(functions)/create-data-backup";
|
import createDataBackup from "../(functions)/create-data-backup";
|
||||||
import UploadRestoreModal from "../(partials)/upload-restore-modal";
|
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
|
* 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() {
|
export default function DataBackupListActions() {
|
||||||
const { loading, setLoading, status, setStatus } = useStatus();
|
const { loading, setLoading, status, setStatus } = useStatus();
|
||||||
|
const { run, error, track } = useContext(DataBackupRunContext);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const archiving = run?.kind == "archive";
|
||||||
|
const restoring = run?.kind == "restore";
|
||||||
|
|
||||||
const create_backup = async () => {
|
const create_backup = async () => {
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
@@ -21,13 +26,13 @@ export default function DataBackupListActions() {
|
|||||||
|
|
||||||
const res = await createDataBackup();
|
const res = await createDataBackup();
|
||||||
|
|
||||||
if (res.success) {
|
setLoading(false);
|
||||||
window.location.reload();
|
|
||||||
|
if (res.run) {
|
||||||
|
track(res.run);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.msg) {
|
if (res.msg) {
|
||||||
setStatus({ error: true, msg: res.msg });
|
setStatus({ error: true, msg: res.msg });
|
||||||
}
|
}
|
||||||
@@ -36,11 +41,19 @@ export default function DataBackupListActions() {
|
|||||||
return (
|
return (
|
||||||
<Row className="gap-2! flex-nowrap">
|
<Row className="gap-2! flex-nowrap">
|
||||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||||
|
{!status?.error && error && <Tag color="error">{error}</Tag>}
|
||||||
|
{archiving && (
|
||||||
|
<Tag color="warning">Archiving, this can take a while...</Tag>
|
||||||
|
)}
|
||||||
|
{restoring && (
|
||||||
|
<Tag color="warning">Restoring, the app restarts soon...</Tag>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
title="Restore from an archive file"
|
title="Restore from an archive file"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="gray"
|
color="gray"
|
||||||
beforeIcon={<Upload />}
|
beforeIcon={<Upload />}
|
||||||
|
disabled={Boolean(run)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
}}
|
}}
|
||||||
@@ -50,7 +63,8 @@ export default function DataBackupListActions() {
|
|||||||
<Button
|
<Button
|
||||||
title="Create Data Archive"
|
title="Create Data Archive"
|
||||||
beforeIcon={<Archive />}
|
beforeIcon={<Archive />}
|
||||||
loading={loading}
|
loading={loading || archiving}
|
||||||
|
disabled={Boolean(run)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
create_backup();
|
create_backup();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,13 +6,8 @@ import restoreDataBackup from "../(functions)/restore-data-backup";
|
|||||||
import deleteDataBackup from "../(functions)/delete-data-backup";
|
import deleteDataBackup from "../(functions)/delete-data-backup";
|
||||||
import RowIconButton from "@/src/components/general/row-icon-button";
|
import RowIconButton from "@/src/components/general/row-icon-button";
|
||||||
import { ArchiveRestore, Download, Trash2 } from "lucide-react";
|
import { ArchiveRestore, Download, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useContext } from "react";
|
||||||
|
import { DataBackupRunContext } from "../(data)/data-backup-run-context";
|
||||||
/**
|
|
||||||
* How long the app is given to come back after a restore restarted it
|
|
||||||
* before the page is reloaded anyway.
|
|
||||||
*/
|
|
||||||
const RELOAD_DELAY_MS = 8000;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
backup: DataBackup;
|
backup: DataBackup;
|
||||||
@@ -21,18 +16,13 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* One data archive's icon actions: restore (replaces the whole data
|
* One data archive's icon actions: restore (replaces the whole data
|
||||||
* volume, then the app restarts), download or delete, with their own
|
* volume, then the app restarts), download or delete, with their own
|
||||||
* one-line status.
|
* one-line status. A restore runs in the background and the section
|
||||||
|
* follows it, so every row waits while one is going.
|
||||||
*/
|
*/
|
||||||
export default function DataBackupRowActions({ backup }: Props) {
|
export default function DataBackupRowActions({ backup }: Props) {
|
||||||
const { loading, setLoading, status, setStatus } = useStatus();
|
const { loading, setLoading, status, setStatus } = useStatus();
|
||||||
const [restarting, setRestarting] = useState(false);
|
const { run, track } = useContext(DataBackupRunContext);
|
||||||
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const busy = Boolean(run);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeout.current) clearTimeout(timeout.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
@@ -58,16 +48,13 @@ export default function DataBackupRowActions({ backup }: Props) {
|
|||||||
|
|
||||||
const res = await restoreDataBackup({ backup });
|
const res = await restoreDataBackup({ backup });
|
||||||
|
|
||||||
if (res.success) {
|
setLoading(false);
|
||||||
setRestarting(true);
|
|
||||||
timeout.current = setTimeout(() => {
|
if (res.run) {
|
||||||
window.location.reload();
|
track(res.run);
|
||||||
}, RELOAD_DELAY_MS);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.msg) {
|
if (res.msg) {
|
||||||
setStatus({ error: true, msg: res.msg });
|
setStatus({ error: true, msg: res.msg });
|
||||||
}
|
}
|
||||||
@@ -76,14 +63,11 @@ export default function DataBackupRowActions({ backup }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Row className="gap-1! flex-nowrap justify-end">
|
<Row className="gap-1! flex-nowrap justify-end">
|
||||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||||
{restarting && (
|
|
||||||
<Tag color="warning">Restoring, the app restarts soon...</Tag>
|
|
||||||
)}
|
|
||||||
<RowIconButton
|
<RowIconButton
|
||||||
icon={ArchiveRestore}
|
icon={ArchiveRestore}
|
||||||
title="Restore this archive"
|
title="Restore this archive"
|
||||||
loading={loading && !restarting}
|
loading={loading}
|
||||||
disabled={restarting}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
restore();
|
restore();
|
||||||
}}
|
}}
|
||||||
@@ -104,7 +88,7 @@ export default function DataBackupRowActions({ backup }: Props) {
|
|||||||
<RowIconButton
|
<RowIconButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
title="Delete this archive"
|
title="Delete this archive"
|
||||||
disabled={loading || restarting}
|
disabled={loading || busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
remove();
|
remove();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import Stack from "@moduletrace/twui/layout/Stack";
|
|||||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
|
import type { DataBackupRun } from "@/src/types";
|
||||||
|
|
||||||
|
type UploadResult = {
|
||||||
|
success: boolean;
|
||||||
|
msg?: string;
|
||||||
|
/**
|
||||||
|
* The background restore a data archive upload started.
|
||||||
|
*/
|
||||||
|
run?: DataBackupRun;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -23,12 +33,12 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Sends the file and reports the outcome.
|
* Sends the file and reports the outcome.
|
||||||
*/
|
*/
|
||||||
upload: (params: { file: File }) => Promise<{ success: boolean; msg?: string }>;
|
upload: (params: { file: File }) => Promise<UploadResult>;
|
||||||
/**
|
/**
|
||||||
* Reload the page once it worked (the app restarts after an archive
|
* Called once it worked: a database restore reloads the page, a data
|
||||||
* restore, so the caller waits for it first).
|
* archive restore hands its background run to the page to follow.
|
||||||
*/
|
*/
|
||||||
onDone: () => void;
|
onDone: (res: UploadResult) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +64,7 @@ export default function UploadRestoreFields({
|
|||||||
const res = await upload({ file });
|
const res = await upload({ file });
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
onDone();
|
onDone(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
|||||||
import UploadRestoreFields from "./upload-restore-fields";
|
import UploadRestoreFields from "./upload-restore-fields";
|
||||||
import uploadRestoreBackup from "../(functions)/upload-restore-backup";
|
import uploadRestoreBackup from "../(functions)/upload-restore-backup";
|
||||||
import uploadRestoreDataBackup from "../(functions)/upload-restore-data-backup";
|
import uploadRestoreDataBackup from "../(functions)/upload-restore-data-backup";
|
||||||
|
import { DataBackupRunContext } from "../(data)/data-backup-run-context";
|
||||||
/**
|
import type { DataBackupRun } from "@/src/types";
|
||||||
* How long the app is given to come back after an archive restore
|
import { useContext } from "react";
|
||||||
* restarted it before the page is reloaded anyway.
|
|
||||||
*/
|
|
||||||
const RELOAD_DELAY_MS = 8000;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,18 +18,20 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Uploading a file and restoring it, in a modal over the list it came
|
* Uploading a file and restoring it, in a modal over the list it came
|
||||||
* from. A database file is restored in place; a data archive replaces the
|
* from. A database file is restored in place; a data archive replaces the
|
||||||
* whole data volume, so the page waits for the app to restart.
|
* whole data volume in the background, so the modal closes and the
|
||||||
|
* section follows the run until the app is back from its restart.
|
||||||
*/
|
*/
|
||||||
export default function UploadRestoreModal({ open, onClose, is_data }: Props) {
|
export default function UploadRestoreModal({ open, onClose, is_data }: Props) {
|
||||||
const reload = () => {
|
const { track } = useContext(DataBackupRunContext);
|
||||||
if (!is_data) {
|
|
||||||
|
const done = ({ run }: { run?: DataBackupRun }) => {
|
||||||
|
if (!is_data || !run) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
track(run);
|
||||||
window.location.reload();
|
onClose();
|
||||||
}, RELOAD_DELAY_MS);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +63,7 @@ export default function UploadRestoreModal({ open, onClose, is_data }: Props) {
|
|||||||
? uploadRestoreDataBackup
|
? uploadRestoreDataBackup
|
||||||
: uploadRestoreBackup
|
: uploadRestoreBackup
|
||||||
}
|
}
|
||||||
onDone={reload}
|
onDone={done}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</AddModalShell>
|
</AddModalShell>
|
||||||
|
|||||||
@@ -5,18 +5,24 @@ import SettingsGroup from "../../(partials)/settings-group";
|
|||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import DataBackupListActions from "../(partials)/data-backup-list-actions";
|
import DataBackupListActions from "../(partials)/data-backup-list-actions";
|
||||||
import DataBackupsTable from "../(partials)/data-backups-table";
|
import DataBackupsTable from "../(partials)/data-backups-table";
|
||||||
|
import { DataBackupRunContext } from "../(data)/data-backup-run-context";
|
||||||
|
import useDataBackupRun from "../(hooks)/use-data-backup-run";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tar.gz archives of the whole data volume, newest first. Each one
|
* The tar.gz archives of the whole data volume, newest first. Each one
|
||||||
* restores (replacing every folder and the database snapshots with the
|
* restores (replacing every folder and the database snapshots with the
|
||||||
* archive's own, then the app restarts), downloads or deletes; the newest
|
* archive's own, then the app restarts), downloads or deletes; the newest
|
||||||
* `AppData.DataBackupsKept` are kept and older ones pruned.
|
* `AppData.DataBackupsKept` are kept and older ones pruned. Archives and
|
||||||
|
* restores run in the background on the server; the section follows the
|
||||||
|
* one running (`useDataBackupRun`) for every control in it.
|
||||||
*/
|
*/
|
||||||
export default function DataBackupsSection() {
|
export default function DataBackupsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const backups = pageProps?.data_backups || [];
|
const backups = pageProps?.data_backups || [];
|
||||||
|
const data_backup_run = useDataBackupRun();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DataBackupRunContext.Provider value={data_backup_run}>
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Data archives"
|
title="Data archives"
|
||||||
description="One tar.gz of the whole data volume, under a single folder named after the archive: the database, media, computer homes, every bot and workspace file, and the database snapshots. They live outside the data volume, and the newest few are kept."
|
description="One tar.gz of the whole data volume, under a single folder named after the archive: the database, media, computer homes, every bot and workspace file, and the database snapshots. They live outside the data volume, and the newest few are kept."
|
||||||
@@ -35,5 +41,6 @@ export default function DataBackupsSection() {
|
|||||||
</Span>
|
</Span>
|
||||||
)}
|
)}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
</DataBackupRunContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
import createDataBackup from "@/src/functions/backend/backups/create-data-backup";
|
import createDataBackup from "@/src/functions/backend/backups/create-data-backup";
|
||||||
|
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { DataBackupRun } from "@/src/types";
|
||||||
import type {
|
import type {
|
||||||
APIResponseObject,
|
APIResponseObject,
|
||||||
BunextAPIRouteHandler,
|
BunextAPIRouteHandler,
|
||||||
} from "@moduletrace/bunext/types";
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archives the whole `DATA_DIR` (Settings > Backups > Data archives).
|
* Starts archiving the whole `DATA_DIR` (Settings > Backups > Data
|
||||||
|
* archives) and answers at once: the page polls `data-status` until the
|
||||||
|
* archive is written.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
export const handler: BunextAPIRouteHandler<
|
||||||
req,
|
APIResponseObject<DataBackupRun>
|
||||||
}) => {
|
> = async ({ req }) => {
|
||||||
const user = await grabSuperAdminUser({ req });
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
@@ -21,6 +26,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ip_address = grabRequestIP(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = startDataBackupRun({
|
||||||
|
kind: "archive",
|
||||||
|
task: async () => {
|
||||||
try {
|
try {
|
||||||
const backup = await createDataBackup();
|
const backup = await createDataBackup();
|
||||||
|
|
||||||
@@ -30,23 +41,28 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
plain_text: `User #${user.id} created a data backup (${backup.name}).`,
|
plain_text: `User #${user.id} created a data backup (${backup.name}).`,
|
||||||
metadata: { backup_name: backup.name },
|
metadata: { backup_name: backup.name },
|
||||||
ip_address: grabRequestIP(req),
|
ip_address,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return backup.name;
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await logError({
|
await logError({
|
||||||
title: "Data backup failed",
|
title: "Data backup failed",
|
||||||
error,
|
error,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
ip_address: grabRequestIP(req),
|
ip_address,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
msg: error.message,
|
singleRes: run,
|
||||||
};
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabDataBackups from "@/src/functions/backend/backups/grab-data-backups";
|
||||||
|
import { grabDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { DataBackupStatus } from "@/src/types";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the whole-data archive or restore this process started is going,
|
||||||
|
* with the archives as they stand, for the backups page to poll.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<DataBackupStatus>
|
||||||
|
> = async ({ req }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "GET") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
msg: `Unauthorized`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: {
|
||||||
|
run: grabDataBackupRun(),
|
||||||
|
data_backups: grabDataBackups(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
||||||
|
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||||
import restartApp from "@/src/functions/backend/backups/restart-app";
|
import restartApp from "@/src/functions/backend/backups/restart-app";
|
||||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||||
import type { ApiReqParams } from "@/src/types";
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { ApiReqParams, DataBackupRun } from "@/src/types";
|
||||||
import type {
|
import type {
|
||||||
APIResponseObject,
|
APIResponseObject,
|
||||||
BunextAPIRouteHandler,
|
BunextAPIRouteHandler,
|
||||||
} from "@moduletrace/bunext/types";
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole data volume with a saved archive, then restarts the
|
* Starts replacing the whole data volume with a saved archive and answers
|
||||||
* app so it opens the restored database.
|
* at once. Once it is in, the app restarts so it opens the restored
|
||||||
|
* database; the page polls `data-status` until it is back.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
export const handler: BunextAPIRouteHandler<
|
||||||
req,
|
APIResponseObject<DataBackupRun>
|
||||||
body,
|
> = async ({ req, body }) => {
|
||||||
}) => {
|
|
||||||
const user = await grabSuperAdminUser({ req });
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
@@ -26,7 +28,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backup_name = (body as ApiReqParams | undefined)?.backup_name;
|
const backup_name = (body as ApiReqParams | undefined)?.backup_name;
|
||||||
|
const ip_address = grabRequestIP(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = startDataBackupRun({
|
||||||
|
kind: "restore",
|
||||||
|
task: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await restoreDataBackup({ backup_name });
|
const result = await restoreDataBackup({ backup_name });
|
||||||
|
|
||||||
@@ -39,14 +46,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
backup_name: result.restored_name,
|
backup_name: result.restored_name,
|
||||||
safety_name: result.safety_name,
|
safety_name: result.safety_name,
|
||||||
},
|
},
|
||||||
ip_address: grabRequestIP(req),
|
ip_address,
|
||||||
});
|
});
|
||||||
|
|
||||||
restartApp();
|
restartApp();
|
||||||
|
|
||||||
return {
|
return result.restored_name;
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await logError({
|
await logError({
|
||||||
title: "Data restore failed",
|
title: "Data restore failed",
|
||||||
@@ -55,12 +60,19 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
backup_name,
|
backup_name,
|
||||||
},
|
},
|
||||||
ip_address: grabRequestIP(req),
|
ip_address,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
msg: error.message,
|
singleRes: run,
|
||||||
};
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
||||||
|
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||||
|
import { isBackupRunning } from "@/src/functions/backend/backups/with-backup-run-lock";
|
||||||
import restartApp from "@/src/functions/backend/backups/restart-app";
|
import restartApp from "@/src/functions/backend/backups/restart-app";
|
||||||
import saveUploadedDataBackup from "@/src/functions/backend/backups/save-uploaded-data-backup";
|
import saveUploadedDataBackup from "@/src/functions/backend/backups/save-uploaded-data-backup";
|
||||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { DataBackupRun } from "@/src/types";
|
||||||
import type {
|
import type {
|
||||||
APIResponseObject,
|
APIResponseObject,
|
||||||
BunextAPIRouteHandler,
|
BunextAPIRouteHandler,
|
||||||
@@ -15,11 +20,14 @@ export const config: BunextServerRouteConfig = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Multipart upload (`file` field) of a whole-data archive. The file is
|
* Multipart upload (`file` field) of a whole-data archive. The file is
|
||||||
* saved into the archive directory, then restored, and the app restarts.
|
* saved into the archive directory within the request (its bytes keep the
|
||||||
|
* connection busy), then the restore starts in the background and the
|
||||||
|
* request answers: the app restarts once it is in, and the page polls
|
||||||
|
* `data-status` until it is back.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
export const handler: BunextAPIRouteHandler<
|
||||||
req,
|
APIResponseObject<DataBackupRun>
|
||||||
}) => {
|
> = async ({ req }) => {
|
||||||
const user = await grabSuperAdminUser({ req });
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
@@ -29,20 +37,46 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ip_address = grabRequestIP(req);
|
||||||
let file_name: string | undefined;
|
let file_name: string | undefined;
|
||||||
let backup_name: string | undefined;
|
let backup_name: string | undefined;
|
||||||
|
|
||||||
|
const logFailure = async (error: unknown) => {
|
||||||
|
await logError({
|
||||||
|
title: "Data restore from upload failed",
|
||||||
|
error,
|
||||||
|
user_id: user.id,
|
||||||
|
metadata: {
|
||||||
|
file_name,
|
||||||
|
backup_name,
|
||||||
|
},
|
||||||
|
ip_address,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isBackupRunning()) {
|
||||||
|
throw new AppError(`Another backup or restore is already running`);
|
||||||
|
}
|
||||||
|
|
||||||
const file = (await req.formData()).get("file");
|
const file = (await req.formData()).get("file");
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
throw new Error(`No archive uploaded`);
|
throw new AppError(`No archive uploaded`);
|
||||||
}
|
}
|
||||||
|
|
||||||
file_name = file.name;
|
file_name = file.name;
|
||||||
backup_name = await saveUploadedDataBackup({ file });
|
backup_name = await saveUploadedDataBackup({ file });
|
||||||
|
|
||||||
const result = await restoreDataBackup({ backup_name });
|
const saved_name = backup_name;
|
||||||
|
|
||||||
|
const run = startDataBackupRun({
|
||||||
|
kind: "restore",
|
||||||
|
task: async () => {
|
||||||
|
try {
|
||||||
|
const result = await restoreDataBackup({
|
||||||
|
backup_name: saved_name,
|
||||||
|
});
|
||||||
|
|
||||||
await logEvent({
|
await logEvent({
|
||||||
title: "Data restored from upload",
|
title: "Data restored from upload",
|
||||||
@@ -54,29 +88,31 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
backup_name: result.restored_name,
|
backup_name: result.restored_name,
|
||||||
safety_name: result.safety_name,
|
safety_name: result.safety_name,
|
||||||
},
|
},
|
||||||
ip_address: grabRequestIP(req),
|
ip_address,
|
||||||
});
|
});
|
||||||
|
|
||||||
restartApp();
|
restartApp();
|
||||||
|
|
||||||
return {
|
return result.restored_name;
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await logError({
|
await logFailure(error);
|
||||||
title: "Data restore from upload failed",
|
throw error;
|
||||||
error,
|
}
|
||||||
user_id: user.id,
|
|
||||||
metadata: {
|
|
||||||
file_name,
|
|
||||||
backup_name,
|
|
||||||
},
|
},
|
||||||
ip_address: grabRequestIP(req),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: run,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof AppError)) {
|
||||||
|
await logFailure(error);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
msg: error.message,
|
msg: safeErrorMessage({ error, log: false }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1212,6 +1212,32 @@ export type DataBackup = {
|
|||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataBackupRunKind = "archive" | "restore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole-data archive or restore this process last started
|
||||||
|
* (`startDataBackupRun`), which the backups page polls while it runs.
|
||||||
|
* `boot_id` changes when the process restarts, which is how the page
|
||||||
|
* knows a restore finished.
|
||||||
|
*/
|
||||||
|
export type DataBackupRun = {
|
||||||
|
status: "idle" | "running" | "done" | "failed";
|
||||||
|
kind?: DataBackupRunKind;
|
||||||
|
started_at?: number;
|
||||||
|
finished_at?: number;
|
||||||
|
/**
|
||||||
|
* The archive written or restored.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
error?: string;
|
||||||
|
boot_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataBackupStatus = {
|
||||||
|
run: DataBackupRun;
|
||||||
|
data_backups: DataBackup[];
|
||||||
|
};
|
||||||
|
|
||||||
export type BotVoicePayload = {
|
export type BotVoicePayload = {
|
||||||
transcription: boolean;
|
transcription: boolean;
|
||||||
speech: boolean;
|
speech: boolean;
|
||||||
@@ -1328,6 +1354,7 @@ export type PagePropsType = {
|
|||||||
has_password?: boolean;
|
has_password?: boolean;
|
||||||
backups?: DbBackup[] | null;
|
backups?: DbBackup[] | null;
|
||||||
data_backups?: DataBackup[] | null;
|
data_backups?: DataBackup[] | null;
|
||||||
|
data_backup_run?: DataBackupRun | null;
|
||||||
harness_overview?: HarnessOverviewPayload | null;
|
harness_overview?: HarnessOverviewPayload | null;
|
||||||
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
envs_overview?: EnvsOverviewPayload | null;
|
envs_overview?: EnvsOverviewPayload | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user