feat(web): add backup page and reorganize admin navigation

Move workflow backup/restore to a dedicated page, rename Ops to Manage,
and group sidebar links into Automate, Platform, and Admin sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 11:00:59 +07:00
co-authored by Cursor
parent eb94a2f669
commit f74b0defc6
5 changed files with 228 additions and 94 deletions
+6 -1
View File
@@ -21,6 +21,7 @@ import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx";
import { VariablesPage } from "./pages/VariablesPage.jsx"; import { VariablesPage } from "./pages/VariablesPage.jsx";
import { OpsPage } from "./pages/OpsPage.jsx"; import { OpsPage } from "./pages/OpsPage.jsx";
import { BackupPage } from "./pages/BackupPage.jsx";
export function App() { export function App() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -74,13 +75,17 @@ export function App() {
<> <>
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/secrets" element={<SecretsPage />} /> <Route path="/secrets" element={<SecretsPage />} />
<Route path="/ops" element={<OpsPage />} /> <Route path="/manage" element={<OpsPage />} />
<Route path="/ops" element={<Navigate to="/manage" replace />} />
<Route path="/backup" element={<BackupPage />} />
</> </>
) : ( ) : (
<> <>
<Route path="/users" element={<Navigate to="/" replace />} /> <Route path="/users" element={<Navigate to="/" replace />} />
<Route path="/secrets" element={<Navigate to="/" replace />} /> <Route path="/secrets" element={<Navigate to="/" replace />} />
<Route path="/manage" element={<Navigate to="/" replace />} />
<Route path="/ops" element={<Navigate to="/" replace />} /> <Route path="/ops" element={<Navigate to="/" replace />} />
<Route path="/backup" element={<Navigate to="/" replace />} />
</> </>
)} )}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+49 -18
View File
@@ -1,6 +1,8 @@
import { Fragment } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom"; import { Link, NavLink, useNavigate } from "react-router-dom";
import { import {
LuActivity, LuActivity,
LuArchive,
LuCode, LuCode,
LuDatabase, LuDatabase,
LuFileText, LuFileText,
@@ -20,17 +22,50 @@ import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js"; import { brandMark } from "../theme/brand.js";
import { useTheme } from "../theme.jsx"; import { useTheme } from "../theme.jsx";
const links = [ const navSections = [
{ to: "/", label: "Home", icon: LuHouse, end: true }, {
{ to: "/scripts", label: "Scripts", icon: LuCode }, items: [{ to: "/", label: "Home", icon: LuHouse, end: true }],
},
{
title: "Automate",
items: [
{ to: "/workflows", label: "Workflows", icon: LuGitBranch }, { to: "/workflows", label: "Workflows", icon: LuGitBranch },
{ to: "/scripts", label: "Scripts", icon: LuCode },
{ to: "/events", label: "Events", icon: LuActivity }, { to: "/events", label: "Events", icon: LuActivity },
{ to: "/kv", label: "KV", icon: LuDatabase }, ],
},
{
title: "Platform",
items: [
{ to: "/variables", label: "Variables", icon: LuTags }, { to: "/variables", label: "Variables", icon: LuTags },
{ to: "/kv", label: "KV", icon: LuDatabase },
{ to: "/auth", label: "Auth", icon: LuShield }, { to: "/auth", label: "Auth", icon: LuShield },
{ to: "/responses", label: "Responses", icon: LuFileText }, { to: "/responses", label: "Responses", icon: LuFileText },
{ to: "/secrets", label: "Secrets", icon: LuKey, admin: true },
],
},
{
title: "Admin",
admin: true,
items: [
{ to: "/manage", label: "Manage", icon: LuServer },
{ to: "/backup", label: "Backup", icon: LuArchive },
{ to: "/users", label: "Users", icon: LuUsers },
],
},
]; ];
function visibleSections(role) {
const isAdmin = role === "admin";
return navSections
.filter((s) => !s.admin || isAdmin)
.map((s) => ({
...s,
items: s.items.filter((item) => !item.admin || isAdmin),
}))
.filter((s) => s.items.length > 0);
}
export function Layout({ user, children }) { export function Layout({ user, children }) {
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const logout = useLogout(); const logout = useLogout();
@@ -38,17 +73,6 @@ export function Layout({ user, children }) {
const ops = useOpsStatus(user.role === "admin"); const ops = useOpsStatus(user.role === "admin");
const restartNeeded = Boolean(ops.data?.desired?.restartNeeded); const restartNeeded = Boolean(ops.data?.desired?.restartNeeded);
const navItems = [
...links,
...(user.role === "admin"
? [
{ to: "/secrets", label: "Secrets", icon: LuKey },
{ to: "/users", label: "Users", icon: LuUsers },
{ to: "/ops", label: "Ops", icon: LuServer },
]
: []),
];
function closeDrawer() { function closeDrawer() {
const el = document.getElementById("nav-drawer"); const el = document.getElementById("nav-drawer");
if (el) el.checked = false; if (el) el.checked = false;
@@ -101,8 +125,8 @@ export function Layout({ user, children }) {
? ` (${ops.data.desired.restartReason})` ? ` (${ops.data.desired.restartReason})`
: ""} : ""}
.{" "} .{" "}
<Link to="/ops" className="link font-medium"> <Link to="/manage" className="link font-medium">
Drain restart from Ops Drain restart from Manage
</Link>{" "} </Link>{" "}
to apply on HTTP + workers. to apply on HTTP + workers.
</span> </span>
@@ -120,7 +144,12 @@ export function Layout({ user, children }) {
<span className="text-base font-semibold text-base-content">JerapahFlow</span> <span className="text-base font-semibold text-base-content">JerapahFlow</span>
</div> </div>
</li> </li>
{navItems.map(({ to, label, icon: Icon, end }) => ( {visibleSections(user.role).map((section) => (
<Fragment key={section.title ?? "overview"}>
{section.title ? (
<li className="menu-title">{section.title}</li>
) : null}
{section.items.map(({ to, label, icon: Icon, end }) => (
<li key={to}> <li key={to}>
<NavLink to={to} end={end} onClick={closeDrawer}> <NavLink to={to} end={end} onClick={closeDrawer}>
<Icon className="size-4" /> <Icon className="size-4" />
@@ -128,6 +157,8 @@ export function Layout({ user, children }) {
</NavLink> </NavLink>
</li> </li>
))} ))}
</Fragment>
))}
</ul> </ul>
</div> </div>
</div> </div>
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { LuDownload, LuUpload } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDownloadWorkflowBackup,
useRestoreWorkflowBackup,
} from "../api/hooks.js";
import { useNotifications } from "../notifications.jsx";
export function BackupPage() {
const backup = useDownloadWorkflowBackup();
const restoreBackup = useRestoreWorkflowBackup();
const { notify } = useNotifications();
const [restoreMode, setRestoreMode] = useState("merge");
const [pendingFile, setPendingFile] = useState(null);
function download() {
backup.mutate(undefined, {
onSuccess: () => notify.success("Backup downloaded"),
onError: (err) => notify.error(errorMessage(err)),
});
}
function restore(file, mode) {
restoreBackup.mutate(
{ file, mode },
{
onSuccess: (data) => {
notify.success("Backup restored");
if (data.warnings?.length) {
notify.warning(data.warnings.join("; "));
}
},
onError: (err) => notify.error(errorMessage(err)),
onSettled: () => setPendingFile(null),
},
);
}
function onPickFile(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (restoreMode === "replace") {
setPendingFile(file);
return;
}
restore(file, restoreMode);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-semibold">Backup</h1>
<p className="text-sm opacity-70">
Download or restore workflows and installed plugins as a zip archive.
</p>
</div>
<section className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<h2 className="card-title text-base">Download</h2>
<p className="text-sm opacity-70">
Includes workflow YAML and installed plugins. Use this to copy the
setup to another machine or keep a snapshot before a restore.
</p>
<div>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={backup.isPending}
onClick={download}
>
{backup.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuDownload className="size-4" />
)}
Download backup
</button>
</div>
</div>
</section>
<section className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<h2 className="card-title text-base">Restore</h2>
<p className="text-sm opacity-70">
Merge keeps existing files and overwrites matches from the zip.
Replace deletes current workflows and plugins first.
</p>
<div className="flex flex-wrap items-center gap-2">
<select
className="select select-bordered select-sm"
value={restoreMode}
onChange={(e) => setRestoreMode(e.target.value)}
aria-label="Restore mode"
>
<option value="merge">Merge</option>
<option value="replace">Replace</option>
</select>
<label
className={`btn btn-sm ${restoreBackup.isPending ? "btn-disabled" : ""}`}
>
{restoreBackup.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuUpload className="size-4" />
)}
Restore from zip
<input
type="file"
accept=".zip,application/zip"
className="hidden"
disabled={restoreBackup.isPending}
onChange={onPickFile}
/>
</label>
</div>
</div>
</section>
{pendingFile ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">Replace with this backup?</h3>
<p className="mt-2 text-sm opacity-70">
Current workflows and plugins will be deleted, then{" "}
<span className="font-mono">{pendingFile.name}</span> will be
restored. This cannot be undone from this page.
</p>
<div className="modal-action">
<button
type="button"
className="btn btn-ghost"
onClick={() => setPendingFile(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-error"
disabled={restoreBackup.isPending}
onClick={() => restore(pendingFile, "replace")}
>
Replace
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setPendingFile(null)}>
close
</button>
</form>
</dialog>
) : null}
</div>
);
}
+3 -7
View File
@@ -55,7 +55,7 @@ export function OpsPage() {
if (unavailable) { if (unavailable) {
return ( return (
<div className="mx-auto max-w-3xl space-y-4"> <div className="mx-auto max-w-3xl space-y-4">
<h1 className="text-2xl font-semibold">Ops</h1> <h1 className="text-2xl font-semibold">Manage</h1>
<div className="alert alert-warning"> <div className="alert alert-warning">
<span> <span>
Control plane is not reachable on <code>/ops</code>. Use{" "} Control plane is not reachable on <code>/ops</code>. Use{" "}
@@ -74,14 +74,10 @@ export function OpsPage() {
const children = data?.children; const children = data?.children;
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-semibold">Ops</h1> <h1 className="text-2xl font-semibold">Manage</h1>
<p className="text-sm opacity-70">
Process control via PM2. Pause stops workers from opening jobs;
cron/HTTP can still enqueue. Restart drains active jobs unless forced.
</p>
</div> </div>
<button <button
type="button" type="button"
-57
View File
@@ -4,8 +4,6 @@ import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, Lu
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useDeleteWorkflow, useDeleteWorkflow,
useDownloadWorkflowBackup,
useRestoreWorkflowBackup,
useReregisterWorkflows, useReregisterWorkflows,
useRunWorkflow, useRunWorkflow,
useSetWorkflowEnabled, useSetWorkflowEnabled,
@@ -14,7 +12,6 @@ import {
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx"; import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
import { useNotifications } from "../notifications.jsx";
export function WorkflowsPage() { export function WorkflowsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,10 +25,6 @@ export function WorkflowsPage() {
const run = useRunWorkflow(); const run = useRunWorkflow();
const setEnabled = useSetWorkflowEnabled(); const setEnabled = useSetWorkflowEnabled();
const reregister = useReregisterWorkflows(); const reregister = useReregisterWorkflows();
const backup = useDownloadWorkflowBackup();
const restoreBackup = useRestoreWorkflowBackup();
const { notify } = useNotifications();
const [restoreMode, setRestoreMode] = useState("merge");
if (editParam) { if (editParam) {
const slash = editParam.indexOf("/"); const slash = editParam.indexOf("/");
@@ -113,56 +106,6 @@ export function WorkflowsPage() {
<p className="text-error text-sm">{errorMessage(setEnabled.error)}</p> <p className="text-error text-sm">{errorMessage(setEnabled.error)}</p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2 text-sm">
<button
type="button"
className="btn btn-ghost btn-xs"
disabled={backup.isPending}
onClick={() =>
backup.mutate(undefined, {
onSuccess: () => notify.success("Backup downloaded"),
onError: (err) => notify.error(errorMessage(err)),
})
}
>
Download backup
</button>
<label className="btn btn-ghost btn-xs cursor-pointer">
Restore backup
<input
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
restoreBackup.mutate(
{ file, mode: restoreMode },
{
onSuccess: (data) => {
notify.success("Backup restored");
if (data.warnings?.length) {
notify.warning(data.warnings.join("; "));
}
},
onError: (err) => notify.error(errorMessage(err)),
},
);
}}
/>
</label>
<select
className="select select-xs"
value={restoreMode}
onChange={(e) => setRestoreMode(e.target.value)}
aria-label="Restore mode"
>
<option value="merge">Merge restore</option>
<option value="replace">Replace restore</option>
</select>
</div>
{isLoading ? ( {isLoading ? (
<span className="loading loading-spinner" /> <span className="loading loading-spinner" />
) : ( ) : (