diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx
index ccc3713..c191915 100644
--- a/packages/web/src/App.jsx
+++ b/packages/web/src/App.jsx
@@ -21,6 +21,7 @@ import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx";
import { VariablesPage } from "./pages/VariablesPage.jsx";
import { OpsPage } from "./pages/OpsPage.jsx";
+import { BackupPage } from "./pages/BackupPage.jsx";
export function App() {
const qc = useQueryClient();
@@ -74,13 +75,17 @@ export function App() {
<>
} />
} />
- } />
+ } />
+ } />
+ } />
>
) : (
<>
} />
} />
+ } />
} />
+ } />
>
)}
} />
diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx
index 737fded..45fd436 100644
--- a/packages/web/src/components/Layout.jsx
+++ b/packages/web/src/components/Layout.jsx
@@ -1,6 +1,8 @@
+import { Fragment } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import {
LuActivity,
+ LuArchive,
LuCode,
LuDatabase,
LuFileText,
@@ -20,17 +22,50 @@ import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js";
import { useTheme } from "../theme.jsx";
-const links = [
- { to: "/", label: "Home", icon: LuHouse, end: true },
- { to: "/scripts", label: "Scripts", icon: LuCode },
- { to: "/workflows", label: "Workflows", icon: LuGitBranch },
- { to: "/events", label: "Events", icon: LuActivity },
- { to: "/kv", label: "KV", icon: LuDatabase },
- { to: "/variables", label: "Variables", icon: LuTags },
- { to: "/auth", label: "Auth", icon: LuShield },
- { to: "/responses", label: "Responses", icon: LuFileText },
+const navSections = [
+ {
+ items: [{ to: "/", label: "Home", icon: LuHouse, end: true }],
+ },
+ {
+ title: "Automate",
+ items: [
+ { to: "/workflows", label: "Workflows", icon: LuGitBranch },
+ { to: "/scripts", label: "Scripts", icon: LuCode },
+ { to: "/events", label: "Events", icon: LuActivity },
+ ],
+ },
+ {
+ title: "Platform",
+ items: [
+ { to: "/variables", label: "Variables", icon: LuTags },
+ { to: "/kv", label: "KV", icon: LuDatabase },
+ { to: "/auth", label: "Auth", icon: LuShield },
+ { 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 }) {
const { theme, toggle } = useTheme();
const logout = useLogout();
@@ -38,17 +73,6 @@ export function Layout({ user, children }) {
const ops = useOpsStatus(user.role === "admin");
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() {
const el = document.getElementById("nav-drawer");
if (el) el.checked = false;
@@ -101,8 +125,8 @@ export function Layout({ user, children }) {
? ` (${ops.data.desired.restartReason})`
: ""}
.{" "}
-
- Drain restart from Ops
+
+ Drain restart from Manage
{" "}
to apply on HTTP + workers.
@@ -120,13 +144,20 @@ export function Layout({ user, children }) {
JerapahFlow
- {navItems.map(({ to, label, icon: Icon, end }) => (
-
-
-
- {label}
-
-
+ {visibleSections(user.role).map((section) => (
+
+ {section.title ? (
+ {section.title}
+ ) : null}
+ {section.items.map(({ to, label, icon: Icon, end }) => (
+
+
+
+ {label}
+
+
+ ))}
+
))}
diff --git a/packages/web/src/pages/BackupPage.jsx b/packages/web/src/pages/BackupPage.jsx
new file mode 100644
index 0000000..0921ba4
--- /dev/null
+++ b/packages/web/src/pages/BackupPage.jsx
@@ -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 (
+
+
+
Backup
+
+ Download or restore workflows and installed plugins as a zip archive.
+
+
+
+
+
+
Download
+
+ Includes workflow YAML and installed plugins. Use this to copy the
+ setup to another machine or keep a snapshot before a restore.
+
+
+
+
+
+
+
+
+
+
Restore
+
+ Merge keeps existing files and overwrites matches from the zip.
+ Replace deletes current workflows and plugins first.
+
+
+
+
+
+
+
+
+ {pendingFile ? (
+
+ ) : null}
+
+ );
+}
diff --git a/packages/web/src/pages/OpsPage.jsx b/packages/web/src/pages/OpsPage.jsx
index 7fc559e..72ca3a6 100644
--- a/packages/web/src/pages/OpsPage.jsx
+++ b/packages/web/src/pages/OpsPage.jsx
@@ -55,7 +55,7 @@ export function OpsPage() {
if (unavailable) {
return (
-
Ops
+
Manage
Control plane is not reachable on /ops. Use{" "}
@@ -74,14 +74,10 @@ export function OpsPage() {
const children = data?.children;
return (
-
+
-
Ops
-
- Process control via PM2. Pause stops workers from opening jobs;
- cron/HTTP can still enqueue. Restart drains active jobs unless forced.
-
+
Manage