Move backups into the settings pages

Admin settings gains a super-admin Backups page at
/admin/settings/backups, with restore nested at .../backups/restore.
Both use SettingsShell and SettingsGroup like the other settings sub
pages instead of the retired admin hero.
This commit is contained in:
2026-10-19 17:37:03 +01:00
parent 53cf054d70
commit b390306166
30 changed files with 183 additions and 165 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
### Auth ### Auth
- **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request. - **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request.
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People and backups exist in code but are closed until added back to `AdminSections` - No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People exists in code but is closed until added back to `AdminSections`
- No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away - No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away
- Redirects unauthenticated `/admin/*` → `/auth/login` - Redirects unauthenticated `/admin/*` → `/auth/login`
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general` - Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general`
@@ -158,7 +158,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, 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 - Routines, 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
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
- `/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), `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). 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/` and `tools/` (see MCPs and custom tools: 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`)), each 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/people/create/(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), `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). 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/` and `tools/` (see MCPs and custom tools: 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`)), each 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/people/create/(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
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* The admin sections that are live. Any other `/admin/*` path redirects to * The admin sections that are live. Any other `/admin/*` path redirects to
* `AdminHomePath`. People and backups exist in code but stay closed until * `AdminHomePath`. People exists in code but stays closed until it is added
* they are added back here. Logs (Activity) is super admins only. * back here. Logs (Activity) is super admins only.
*/ */
export const AdminSections = [ export const AdminSections = [
"/admin/bots", "/admin/bots",
+9
View File
@@ -2,6 +2,7 @@ import {
Bell, Bell,
Bot, Bot,
CpuAi, CpuAi,
DatabaseBackup,
KeyRound, KeyRound,
Layers, Layers,
Monitor, Monitor,
@@ -32,6 +33,8 @@ export type SettingsPage = {
}; };
export const SettingsComputersPath = "/admin/settings/computers"; export const SettingsComputersPath = "/admin/settings/computers";
export const SettingsBackupsPath = "/admin/settings/backups";
export const SettingsBackupsRestorePath = `${SettingsBackupsPath}/restore`;
/** /**
* The `/admin/settings/*` sub pages, listed in the rail on every settings * The `/admin/settings/*` sub pages, listed in the rail on every settings
@@ -77,6 +80,12 @@ export const SettingsPages: SettingsPage[] = [
Icon: Variable, Icon: Variable,
super_admin_only: true, super_admin_only: true,
}, },
{
title: "Backups",
url: SettingsBackupsPath,
Icon: DatabaseBackup,
super_admin_only: true,
},
]; ];
export const SettingsDefaultPath = "/admin/settings/general"; export const SettingsDefaultPath = "/admin/settings/general";
@@ -1,19 +0,0 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
type Params = {
label: string;
description: string;
};
export default function backupsPageMeta({
label,
description,
}: Params): BunextPageModuleMetaFn {
return async () => {
return {
title: `${label} | ${SiteData["SiteName"]}`,
description,
};
};
}
@@ -1,27 +0,0 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
/**
* Shared server props for the `/admin/backups` sub-pages. Super admin only.
*/
export default async function grabBackupsServerProps({ req }: Params) {
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
return {
redirect: {
destination: "/admin",
},
};
}
return {
props: {
backups: grabDbBackups(),
},
};
}
@@ -1,32 +0,0 @@
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import Section from "@moduletrace/twui/layout/Section";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table";
type Props = {
mode: BackupsTableMode;
};
export default function BackupsListSection({ mode }: Props) {
const { pageProps } = useContext(AppContext);
const backups = pageProps?.backups || [];
return (
<Section>
<Stack className="w-full items-stretch">
<Span size="small" variant="faded">
{`${backups.length} backup${backups.length == 1 ? "" : "s"}`}
</Span>
{backups[0] ? (
<BackupsTable backups={backups} mode={mode} />
) : (
<EmptyContent title="No backups yet" />
)}
</Stack>
</Section>
);
}
@@ -1,12 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import CreateBackupButton from "../(partials)/create-backup-button";
export default function Hero() {
return (
<AdminHero
title="Backups"
description="Snapshots of the database. Older backups are pruned automatically."
buttons={<CreateBackupButton />}
/>
);
}
-17
View File
@@ -1,17 +0,0 @@
import Hero from "./(sections)/hero";
import BackupsListSection from "./(sections)/backups-list-section";
import backupsPageMeta from "./(functions)/backups-page-meta";
export default function AdminBackupsPage() {
return (
<>
<Hero />
<BackupsListSection mode="view" />
</>
);
}
export const meta = backupsPageMeta({
label: "Backups",
description: "Create and download database backups",
});
@@ -1,10 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
export default function Hero() {
return (
<AdminHero
title="Restore"
description="Replace all current data with the contents of a saved or uploaded backup. This cannot be undone."
/>
);
}
@@ -1,25 +0,0 @@
import Paper from "@moduletrace/twui/elements/Paper";
import H3 from "@moduletrace/twui/layout/H3";
import Section from "@moduletrace/twui/layout/Section";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import UploadRestoreForm from "../(partials)/upload-restore-form";
export default function UploadRestoreSection() {
return (
<Section className="pb-0!">
<Paper>
<Stack className="w-full items-stretch gap-4">
<Stack className="gap-1!">
<H3>Restore from a file</H3>
<Span size="small" variant="faded">
Upload a SQLite database file. It is saved to the
backups list, then restored.
</Span>
</Stack>
<UploadRestoreForm />
</Stack>
</Paper>
</Section>
);
}
-19
View File
@@ -1,19 +0,0 @@
import Hero from "./(sections)/hero";
import UploadRestoreSection from "./(sections)/upload-restore-section";
import BackupsListSection from "../(sections)/backups-list-section";
import backupsPageMeta from "../(functions)/backups-page-meta";
export default function AdminBackupsRestorePage() {
return (
<>
<Hero />
<UploadRestoreSection />
<BackupsListSection mode="restore" />
</>
);
}
export const meta = backupsPageMeta({
label: "Restore Backup",
description: "Restore the database from a backup",
});
@@ -0,0 +1,35 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
/**
* Shared server props for the `/admin/settings/backups` sub-pages: the
* saved snapshots. Super admins only; everyone else lands on the general
* settings.
*/
export default async function grabBackupsServerProps(params: Params) {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
if (!(await grabSuperAdminUser({ req: params.req }))) {
return {
redirect: {
destination: SettingsDefaultPath,
permanent: false,
},
};
}
return {
props: {
...settings.props,
backups: grabDbBackups(),
},
};
}
@@ -0,0 +1,46 @@
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import SettingsGroup from "../../(partials)/settings-group";
import { AppContext } from "@/src/pages/__root";
import CreateBackupButton from "../(partials)/create-backup-button";
import BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table";
type Props = {
mode: BackupsTableMode;
};
/**
* The database snapshots, newest first. On the restore page each row
* restores; otherwise each one downloads, and a new backup can be made.
*/
export default function BackupsListSection({ mode }: Props) {
const { pageProps } = useContext(AppContext);
const backups = pageProps?.backups || [];
const is_restore = mode == "restore";
return (
<SettingsGroup
title={is_restore ? "Saved snapshots" : "Snapshots"}
description={
is_restore
? "Restore any of these, or upload a file below. Create a fresh backup first if you might need the current data."
: "Older snapshots are pruned automatically. Download one to keep it."
}
>
<Row className="w-full items-center justify-between gap-2! flex-wrap">
<Span size="small" variant="faded">
{`${backups.length} backup${backups.length == 1 ? "" : "s"}`}
</Span>
{is_restore ? null : <CreateBackupButton />}
</Row>
{backups[0] ? (
<BackupsTable backups={backups} mode={mode} />
) : (
<Span size="small" variant="faded">
No backups yet.
</Span>
)}
</SettingsGroup>
);
}
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
import { ArchiveRestore } from "lucide-react";
import SettingsGroup from "../../(partials)/settings-group";
import { SettingsBackupsRestorePath } from "@/src/dict/settings-pages-dict";
/**
* The way into the restore page from the backups list.
*/
export default function RestoreBackupsSection() {
return (
<SettingsGroup
title="Restore"
description="Replace all current data with the contents of a saved or uploaded backup. This cannot be undone."
>
<Button
title="Restore backup"
href={SettingsBackupsRestorePath}
variant="outlined"
color="warning"
size="small"
beforeIcon={<ArchiveRestore size={20} />}
className="self-start"
>
Restore a backup
</Button>
</SettingsGroup>
);
}
@@ -0,0 +1,19 @@
import BackupsListSection from "./(sections)/backups-list-section";
import RestoreBackupsSection from "./(sections)/restore-backups-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsBackupsPage() {
return (
<SettingsShell
title="Backups"
description="Snapshots of the database, and restoring one."
wide
>
<BackupsListSection mode="view" />
<RestoreBackupsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Backups" });
@@ -0,0 +1,16 @@
import SettingsGroup from "../../../(partials)/settings-group";
import UploadRestoreForm from "../(partials)/upload-restore-form";
/**
* Restoring from a file on this machine, for a backup kept elsewhere.
*/
export default function UploadRestoreSection() {
return (
<SettingsGroup
title="From a file"
description="Upload a SQLite database file. It is saved to the backups list, then restored."
>
<UploadRestoreForm />
</SettingsGroup>
);
}
@@ -0,0 +1,26 @@
import UploadRestoreSection from "./(sections)/upload-restore-section";
import BackupsListSection from "../(sections)/backups-list-section";
import SettingsShell from "../../(partials)/settings-shell";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
export default function AdminSettingsRestoreBackupPage() {
return (
<SettingsShell
title="Restore backup"
description="Replace all current data with a saved or uploaded backup."
breadcrumb_title="Restore backup"
wide
>
<UploadRestoreSection />
<BackupsListSection mode="restore" />
</SettingsShell>
);
}
export const meta: BunextPageModuleMetaFn = async () => {
return {
title: `Restore backup | ${SiteData["SiteName"]}`,
description: `Restore a database backup on ${SiteData["SiteName"]}.`,
};
};