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:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 }],
|
||||||
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
},
|
||||||
{ to: "/events", label: "Events", icon: LuActivity },
|
{
|
||||||
{ to: "/kv", label: "KV", icon: LuDatabase },
|
title: "Automate",
|
||||||
{ to: "/variables", label: "Variables", icon: LuTags },
|
items: [
|
||||||
{ to: "/auth", label: "Auth", icon: LuShield },
|
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
||||||
{ to: "/responses", label: "Responses", icon: LuFileText },
|
{ 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 }) {
|
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,13 +144,20 @@ 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) => (
|
||||||
<li key={to}>
|
<Fragment key={section.title ?? "overview"}>
|
||||||
<NavLink to={to} end={end} onClick={closeDrawer}>
|
{section.title ? (
|
||||||
<Icon className="size-4" />
|
<li className="menu-title">{section.title}</li>
|
||||||
{label}
|
) : null}
|
||||||
</NavLink>
|
{section.items.map(({ to, label, icon: Icon, end }) => (
|
||||||
</li>
|
<li key={to}>
|
||||||
|
<NavLink to={to} end={end} onClick={closeDrawer}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user