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 ? ( + +
    +

    Replace with this backup?

    +

    + Current workflows and plugins will be deleted, then{" "} + {pendingFile.name} will be + restored. This cannot be undone from this page. +

    +
    + + +
    +
    +
    + +
    +
    + ) : 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

    - - -
    - {isLoading ? ( ) : (