From 85359c83ebfc43399fc8d8254dc2e6dfc0f89d96 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 27 Jul 2026 22:44:28 -0400 Subject: [PATCH 01/12] refactor(nav): dev-gate the Test Sandbox, move Webhooks into Settings Two sidebar surfaces that shouldn't have been top-level pages. Both touch App.jsx + sidebarItems.js, so they land together. Test Sandbox: the distro-matrix console shipped visible and routable on every production install. Add a shared useDevMode() hook (module-scope cache + listeners, same shape as useModules) resolving `import.meta.env.DEV || (isAdmin && dev_mode)`. The sidebar item gates on requiresCondition: 'devMode' and the route on the same hook, so a visible nav entry can never redirect to the dashboard. The guard waits on `resolved` first, or an admin deep link would bounce before the dev_mode fetch lands. Settings' toggle calls refreshDevMode() so the sidebar reacts without a reload. Webhooks: the inbound receive/verify/forward console is server configuration, not a daily ops surface, so it becomes a Settings -> Admin tab next to the outbound subscriptions it complements. /webhooks and /secrets/webhooks redirect there; the sidebar item is gone, which empties ADVANCED_ITEM_IDS down to the queue console. The command palette entry drops its navId (a Settings tab has no nav item for the workspace permission gate to consult) and settingsIndex.js gains the three entries check-settings-index.mjs requires for a new tab. The native confirm() on delete becomes the shared useConfirm() dialog. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.jsx | 26 +++++-- frontend/src/components/Sidebar.jsx | 14 ++-- .../organization/organizationTabs.jsx | 2 +- .../settings/WebhooksTab.jsx} | 40 +++++------ frontend/src/components/sidebarItems.js | 32 ++++----- frontend/src/data/palettePages.js | 4 +- frontend/src/data/settingsIndex.js | 7 ++ frontend/src/hooks/useDevMode.js | 71 +++++++++++++++++++ frontend/src/pages/Settings.jsx | 23 ++++-- frontend/src/pages/TestSandbox.jsx | 6 -- frontend/src/styles/pages/_secrets.scss | 6 +- frontend/src/styles/pages/_test-sandbox.scss | 8 --- 12 files changed, 170 insertions(+), 69 deletions(-) rename frontend/src/{pages/Webhooks.jsx => components/settings/WebhooksTab.jsx} (92%) create mode 100644 frontend/src/hooks/useDevMode.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c69fa47d..ff249f8d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -61,7 +61,6 @@ import FleetProxy from './pages/FleetProxy'; import PublicStatusPage from './pages/PublicStatusPage'; import Marketplace from './pages/Marketplace'; import Vaults from './pages/Vaults'; -import Webhooks from './pages/Webhooks'; import StyleGuide from './pages/StyleGuide'; import NotFound from './pages/NotFound'; import AppMap from './pages/AppMap'; @@ -75,6 +74,7 @@ import DeliveryLog from './pages/DeliveryLog'; import Telemetry from './pages/Telemetry'; import Jobs from './pages/Jobs'; import TestSandbox from './pages/TestSandbox'; +import useDevMode from './hooks/useDevMode'; import useExtensionRoutes from './plugins/ExtensionRoutes'; import { useContributions } from './plugins/contributions'; @@ -129,7 +129,6 @@ const PAGE_TITLES = { '/extensions': 'Extensions', '/extensions/installed': 'Installed Extensions', '/vaults': 'Vaults', - '/webhooks': 'Webhooks', '/style-guide': 'Style Guide', '/app-map': 'App Map', '/documentation': 'Documentation', @@ -198,6 +197,16 @@ function PageTitleUpdater() { return null; } +// Guard for developer-only pages (Test Sandbox). Reads the same shared flag as +// the sidebar's requiresCondition: 'devMode', so the nav entry and the route can +// never disagree. Off ⇒ the URL behaves as if the page doesn't exist. +function DevOnlyRoute({ children }) { + const { devMode, resolved } = useDevMode({ withStatus: true }); + if (!resolved) return ; + if (!devMode) return ; + return children; +} + function PrivateRoute({ children }) { const { isAuthenticated, loading, needsSetup, needsMigration } = useAuth(); @@ -451,15 +460,18 @@ function AppRoutes() { } /> } /> } /> - } /> {/* Retired "Secrets & Webhooks" page: Vaults moved into the - Organization tab group (/vaults), Webhooks got its own page. - Redirect old links/bookmarks to their new homes. */} - } /> + Organization tab group (/vaults); the inbound-webhook console + is now a Settings → Admin tab (it's server configuration, not + a daily ops surface). Redirect old links/bookmarks. */} + } /> + } /> } /> } /> } /> - } /> + {/* Developer tooling, not an operator surface — see the + matching requiresCondition on the sidebar item. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 20769f1f..bc2448a4 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -11,6 +11,7 @@ import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, import { useContributions } from '../plugins/contributions'; import { sanitizeSvgInner } from '../utils/sanitizeSvg'; import useModules from '../hooks/useModules'; +import useDevMode from '../hooks/useDevMode'; const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => { const { user, logout, updateUser, hasPermission } = useAuth(); @@ -117,7 +118,10 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { const { isEnabled: isModuleEnabled } = useModules(); const wordpressEnabled = isModuleEnabled('wordpress'); - const conditions = { wpInstalled, gpuAvailable, wordpressEnabled }; + // Developer-only items (Test Sandbox) — same source the route guard reads. + const devMode = useDevMode(); + + const conditions = { wpInstalled, gpuAvailable, wordpressEnabled, devMode }; const currentPreset = user?.sidebar_config?.preset || 'recommended'; const [manualExpanded, setManualExpanded] = useState({}); const [autoExpanded, setAutoExpanded] = useState(null); @@ -156,9 +160,9 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { category: item.category || 'system', })); // Top-level items can gate on a runtime condition (e.g. GPU Monitor - // only when a GPU is present, or the Email/WordPress modules being - // enabled), mirroring sub-item requiresCondition. - const conds = { wpInstalled, gpuAvailable, wordpressEnabled }; + // only when a GPU is present, the Email/WordPress modules being + // enabled, or dev-only tooling), mirroring sub-item requiresCondition. + const conds = { wpInstalled, gpuAvailable, wordpressEnabled, devMode }; let items = [...core, ...fromPlugins].filter( (item) => !item.requiresCondition || conds[item.requiresCondition] ); @@ -194,7 +198,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { } } return applyWorkspaceNavPermissions(items, activeWorkspace, user); - }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, user, hasPermission]); + }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission]); // Group visible items by category const groupedItems = useMemo(() => { diff --git a/frontend/src/components/organization/organizationTabs.jsx b/frontend/src/components/organization/organizationTabs.jsx index 4483b1fd..f57adfc9 100644 --- a/frontend/src/components/organization/organizationTabs.jsx +++ b/frontend/src/components/organization/organizationTabs.jsx @@ -9,7 +9,7 @@ import { FolderKanban, Braces, KeyRound, LayoutGrid } from 'lucide-react'; // // Vaults (encrypted secret stores) sits beside Shared Variables here because // both are key/value config surfaces; inbound Webhooks — the other half of the -// retired "Secrets & Webhooks" page — now lives on its own /webhooks page. +// retired "Secrets & Webhooks" page — now lives at Settings → Admin → Webhooks. export const ORG_TABS = [ { to: '/projects', label: 'Projects', end: true, icon: }, { to: '/shared-variables', label: 'Shared Variables', icon: }, diff --git a/frontend/src/pages/Webhooks.jsx b/frontend/src/components/settings/WebhooksTab.jsx similarity index 92% rename from frontend/src/pages/Webhooks.jsx rename to frontend/src/components/settings/WebhooksTab.jsx index 28f672a3..0448b6d3 100644 --- a/frontend/src/pages/Webhooks.jsx +++ b/frontend/src/components/settings/WebhooksTab.jsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import api from '../services/api'; -import { PageTopbar } from '@/components/ds'; +import api from '../../services/api'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,20 +9,24 @@ import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { useToast } from '../contexts/ToastContext'; -import { Webhook, Plus, MoreVertical, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react'; -import EmptyState from '../components/EmptyState'; +import { useToast } from '../../contexts/ToastContext'; +import { useConfirm } from '../../hooks/useConfirm'; +import { Plus, MoreVertical, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react'; +import EmptyState from '../EmptyState'; const formatDate = (d) => (d ? new Date(d).toLocaleString() : '—'); /** - * Webhooks — receive, verify, and forward inbound webhooks. Split out of the - * old "Secrets & Webhooks" page: secret storage now lives under the - * Organization tab group (/vaults). This is a standalone page with its own - * PageTopbar. + * Webhooks — receive, verify, and forward inbound webhooks. + * + * Lives as a Settings → Admin tab rather than a top-level page: it's server + * configuration, not a daily operations surface, and it sits next to the + * outbound notification subscriptions it complements. /webhooks redirects here. + * (Secret storage moved to the Organization tab group at /vaults earlier.) */ -export default function Webhooks() { +export default function WebhooksTab() { const toast = useToast(); + const { confirm } = useConfirm(); const [endpoints, setEndpoints] = useState([]); const [loading, setLoading] = useState(true); @@ -68,7 +71,11 @@ export default function Webhooks() { } async function deleteEndpoint(id) { - if (!confirm('Delete this webhook endpoint?')) return; + const confirmed = await confirm({ + title: 'Delete Webhook Endpoint', + message: 'Delete this webhook endpoint? Inbound deliveries to its URL will stop being accepted.', + }); + if (!confirmed) return; try { await api.deleteWebhookEndpoint(id); if (selectedEndpoint?.id === id) setSelectedEndpoint(null); @@ -118,18 +125,11 @@ export default function Webhooks() { }, [selectedEndpoint]); if (loading) { - return ( -
- } title="Webhooks" /> - -
- ); + return ; } return ( -
- } title="Webhooks" /> - +
{!selectedEndpoint ? ( diff --git a/frontend/src/components/sidebarItems.js b/frontend/src/components/sidebarItems.js index 348b5700..6a386342 100644 --- a/frontend/src/components/sidebarItems.js +++ b/frontend/src/components/sidebarItems.js @@ -185,22 +185,21 @@ export const SIDEBAR_ITEMS = [ // GPU Monitor lives in the standalone serverkit-gpu extension (own repo, // registry-installed); its sidebar item (still gated on gpuAvailable via // requiresCondition) is contributed by the extension manifest. - { - // Inbound webhook console. Secret storage ("Vaults") that used to share - // this page now lives under the Organization tab group (/vaults); only - // the receive/verify/forward half remains here on its own page. - id: 'webhooks', - label: 'Webhooks', - route: '/webhooks', - category: 'system', - icon: '' - }, + // The inbound-webhook console is no longer a sidebar page: it's server + // configuration, so it lives at Settings → Admin → Webhooks alongside the + // outbound notification subscriptions. /webhooks redirects there. { // Admin distro-matrix test console (Docker-backed runs, per-distro // logs). Sits next to Jobs under System. + // + // Developer tooling, not an operator surface: hidden unless this is a + // local `npm run dev` build or an admin has turned on Site Settings → + // Developer mode. The route is gated on the same condition, so a + // production install neither shows nor serves it. id: 'test-sandbox', label: 'Test Sandbox', route: '/test-sandbox', + requiresCondition: 'devMode', category: 'system', icon: '' }, @@ -221,12 +220,13 @@ export const SIDEBAR_ITEMS = [ ]; // "Advanced" items are powerful but not part of the everyday core for a solo -// dev / small team: the internal job-queue console and the inbound-Webhooks -// console. They're hidden by the default ("Recommended") view and every curated -// preset, but stay one click away via the "Full" view or Customize Sidebar — and -// remain fully routable (deep links, command palette). The Marketplace is NOT in -// this list — it's alwaysVisible so extensions are always discoverable. -export const ADVANCED_ITEM_IDS = ['queue', 'webhooks']; +// dev / small team — currently the internal job-queue console. They're hidden by +// the default ("Recommended") view and every curated preset, but stay one click +// away via the "Full" view or Customize Sidebar — and remain fully routable +// (deep links, command palette). The Marketplace is NOT in this list — it's +// alwaysVisible so extensions are always discoverable. ("webhooks" left this +// list when the console moved into Settings → Admin.) +export const ADVANCED_ITEM_IDS = ['queue']; // Preset profiles define which items are hidden (top-level only) export const SIDEBAR_PRESETS = { diff --git a/frontend/src/data/palettePages.js b/frontend/src/data/palettePages.js index 036d7c7b..1054ce8a 100644 --- a/frontend/src/data/palettePages.js +++ b/frontend/src/data/palettePages.js @@ -39,7 +39,9 @@ export const PALETTE_PAGES = [ { label: 'Vaults', path: '/vaults', navId: 'organization', keywords: 'secrets tokens credentials vault' }, { label: 'Queue Bus', path: '/queue', navId: 'queue', keywords: 'bus operations tasks' }, { label: 'Jobs', path: '/jobs', navId: 'jobs', keywords: 'scheduler background work' }, - { label: 'Webhooks', path: '/webhooks', navId: 'webhooks', keywords: 'webhook receive forward delivery' }, + // No navId: the inbound-webhook console is a Settings tab now, not a + // sidebar page, so there's no nav item for the permission gate to consult. + { label: 'Webhooks', path: '/settings/webhooks', keywords: 'webhook receive forward delivery inbound' }, ].map((p) => ({ ...p, id: `page:${p.path}`, category: 'Pages' })); export default PALETTE_PAGES; diff --git a/frontend/src/data/settingsIndex.js b/frontend/src/data/settingsIndex.js index ef5689dd..45ed9d62 100644 --- a/frontend/src/data/settingsIndex.js +++ b/frontend/src/data/settingsIndex.js @@ -90,6 +90,13 @@ export const SETTINGS_INDEX = [ { id: 'api-webhooks', label: 'Webhook subscriptions', description: 'Create and manage webhooks for event notifications', keywords: 'webhook events notifications', tab: 'api', adminOnly: false }, { id: 'api-analytics', label: 'API usage analytics', description: 'Monitor API traffic, response times, and errors', keywords: 'api analytics traffic usage endpoints', tab: 'api', adminOnly: true }, + // Inbound webhooks (the receive/verify/forward console, moved here from the + // retired top-level /webhooks page). Distinct from `api-webhooks` above, + // which is the OUTBOUND event-subscription surface. + { id: 'webhooks-endpoints', label: 'Webhook endpoints', description: 'Create inbound webhook endpoints with a slug, signing secret, and optional forward URL', keywords: 'webhook inbound endpoint receive slug secret forward', tab: 'webhooks', adminOnly: true }, + { id: 'webhooks-deliveries', label: 'Webhook deliveries', description: 'Inspect received webhook deliveries, signature validity, and replay them', keywords: 'webhook delivery replay signature payload history', tab: 'webhooks', adminOnly: true }, + { id: 'webhooks-secret', label: 'Regenerate webhook secret', description: 'Rotate the signing secret used to verify an inbound webhook endpoint', keywords: 'webhook secret rotate regenerate signature hmac', tab: 'webhooks', adminOnly: true }, + { id: 'ai-enable', label: 'Enable AI assistant', description: 'Toggle the in-panel AI assistant on or off', keywords: 'ai assistant enable disable', tab: 'ai', adminOnly: true }, { id: 'ai-provider', label: 'AI provider', description: 'Select the AI service provider (OpenAI, Anthropic, Ollama, etc.)', keywords: 'ai provider openai anthropic ollama', tab: 'ai', adminOnly: true }, { id: 'ai-model', label: 'AI model', description: 'Choose the language model to use', keywords: 'model gpt claude ollama', tab: 'ai', adminOnly: true }, diff --git a/frontend/src/hooks/useDevMode.js b/frontend/src/hooks/useDevMode.js new file mode 100644 index 00000000..a1d98d9c --- /dev/null +++ b/frontend/src/hooks/useDevMode.js @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import api from '../services/api'; +import { useAuth } from '../contexts/AuthContext'; + +// Shared, app-wide "developer mode" flag — the gate for developer-only surfaces +// (currently the Test Sandbox). True when either: +// +// * this is a local `npm run dev` build (import.meta.env.DEV), or +// * an admin has turned on Site Settings → Developer mode (dev_mode). +// +// Cached at module scope like useModules() so the sidebar item and the route +// guard read one value and can never disagree — a visible nav entry that +// redirects to the dashboard is worse than no entry at all. +// +// /admin/settings is admin-gated, so only admins are asked; everyone else stays +// on the `false` default rather than eating a 403 per session. + +let cache = null; // last-fetched dev_mode boolean +let inflight = null; // de-dupes concurrent initial fetches +const listeners = new Set(); + +function notify() { + for (const listener of listeners) listener(cache); +} + +async function fetchDevMode(force = false) { + if (cache !== null && !force) return cache; + if (inflight && !force) return inflight; + inflight = api.getSystemSettings() + .then((data) => { + cache = !!data?.dev_mode; + notify(); + return cache; + }) + .catch(() => { + cache = cache ?? false; + return cache; + }) + .finally(() => { inflight = null; }); + return inflight; +} + +// Let Settings broadcast a toggle without a reload. +export function refreshDevMode() { + return fetchDevMode(true); +} + +// Returns the resolved flag. `resolved` is false only while an admin's first +// dev_mode fetch is still in flight — route guards must wait for it, or a +// deep link into a dev-only page would bounce before the answer arrives. +export function useDevMode({ withStatus = false } = {}) { + const { isAdmin } = useAuth(); + const [systemDevMode, setSystemDevMode] = useState(cache ?? false); + const [loaded, setLoaded] = useState(cache !== null); + + useEffect(() => { + if (!isAdmin) return undefined; + const listener = (v) => { setSystemDevMode(!!v); setLoaded(true); }; + listeners.add(listener); + fetchDevMode().then(() => setLoaded(true)); + return () => { listeners.delete(listener); }; + }, [isAdmin]); + + // A local dev build short-circuits: nothing to wait for. + const devMode = import.meta.env.DEV || (isAdmin && systemDevMode); + const resolved = import.meta.env.DEV || !isAdmin || loaded; + + return withStatus ? { devMode, resolved } : devMode; +} + +export default useDevMode; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index edc10797..c0c8a559 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import useTabParam from '../hooks/useTabParam'; +import { refreshDevMode } from '../hooks/useDevMode'; import { useAuth } from '../contexts/AuthContext'; import api from '../services/api'; import ProfileTab from '../components/settings/ProfileTab'; @@ -19,19 +20,20 @@ import MigrationHistoryTab from '../components/settings/MigrationHistoryTab'; import IconReferenceTab from '../components/settings/IconReferenceTab'; import AISettingsTab from '../components/settings/AISettingsTab'; import ModulesTab from '../components/settings/ModulesTab'; +import WebhooksTab from '../components/settings/WebhooksTab'; import AboutTab from '../components/settings/AboutTab'; import PluginSlot from '../components/PluginSlot'; -import { Activity, Code, Database, Layers, Link2, PaintBucket, Sparkles, Settings as SettingsIcon } from 'lucide-react'; +import { Activity, Code, Database, Layers, Link2, PaintBucket, Sparkles, Webhook, Settings as SettingsIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { PageTopbar, SegControl } from '@/components/ds'; import { useNavigate } from 'react-router-dom'; -const VALID_TABS = ['profile', 'security', 'connections', 'appearance', 'sidebar', 'whitelabel', 'notifications', 'system', 'users', 'activity', 'site', 'sso', 'api', 'ai', 'modules', 'migrations', 'developer', 'about']; +const VALID_TABS = ['profile', 'security', 'connections', 'appearance', 'sidebar', 'whitelabel', 'notifications', 'system', 'users', 'activity', 'site', 'sso', 'api', 'webhooks', 'ai', 'modules', 'migrations', 'developer', 'about']; // Tabs that belong to the server-wide "Administration" group (admin-only); the // rest are personal "My Account" settings. Drives the two-way section switch so // personal prefs aren't interleaved with destructive system controls. -const ADMIN_TABS = ['users', 'activity', 'site', 'connections', 'sso', 'api', 'ai', 'modules', 'migrations', 'system', 'developer']; +const ADMIN_TABS = ['users', 'activity', 'site', 'connections', 'sso', 'api', 'webhooks', 'ai', 'modules', 'migrations', 'system', 'developer']; const Settings = () => { const [activeTab, setActiveTab] = useTabParam('/settings', VALID_TABS); @@ -232,6 +234,14 @@ const Settings = () => { API + - + )} /> {error && ( -
+
{error} - +
)} - {/* KPI strip */} -
- } value={jobs.length} label="Cron jobs"> -
{enabledCount} enabled
-
- } value={enabledCount} label="Active jobs"> - {disabledCount > 0 && ( -
{disabledCount} disabled
- )} -
- } - value={status?.available ? 'Available' : 'Not Available'} - label="Cron service" - > - {serviceSub && ( -
{serviceSub}
- )} -
- } value={status?.platform || 'Unknown'} label="Platform" /> -
- - {/* Jobs list */} - {jobs.length === 0 ? ( + {loading ? ( + + ) : jobs.length === 0 ? ( Create Job} + action={} /> ) : ( - <> +
+ {/* No KPI strip: every number it carried is already on the + segment you'd click to act on it (mirrors /domains). */}
-

Scheduled jobs

-
- - setQuery(e.target.value)} - /> -
- + + {serviceNote && {serviceNote}}
- {shownJobs.length === 0 ? ( -
No jobs match the current filter.
+ {shown.length === 0 ? ( +
+ {q ? `No jobs match “${search.trim()}”.` : 'No jobs match this filter.'} +
) : (
- - - - - - - - - - {shownJobs.map((job) => { - const readable = getScheduleDescription(job.schedule); - return ( - openEditModal(job)} - > - - - - - - ); - })} - -
JobScheduleStatus -
-
- -
-
{job.name || 'Unnamed Job'}
- {job.description && ( -
{job.description}
- )} -
- {job.command} -
-
+ (job.enabled ? undefined : 'is-disabled')} + columns={[ + { + key: 'name', + header: 'Job', + render: (job) => ( +
+ +
+
+ {job.name || 'Unnamed job'}
-
- {job.schedule} - {readable !== job.schedule && ( -
{readable}
- )} -
- - {job.enabled ? 'Enabled' : 'Disabled'} - - e.stopPropagation()}> -
- - - - +
+ {job.command}
-
+
+
+ ), + }, + { + key: 'schedule', + header: 'Schedule', + render: (job) => ( + <> + + {job.schedule} + +
+ {describeSchedule(job)} +
+ + ), + }, + { + key: 'last_run', + header: 'Last run', + cellClassName: 'cron-cell-mono', + render: (job) => formatWhen(job.last_run), + }, + { + key: 'next_run', + header: 'Next run', + cellClassName: 'cron-cell-mono', + render: (job) => ( + job.enabled ? formatWhen(job.next_run) : 'paused' + ), + }, + { + key: 'status', + header: 'Status', + render: (job) => { + const state = jobState(job); + return {state.label}; + }, + }, + { + key: 'enabled', + header: 'On', + render: (job) => ( + e.stopPropagation()} + role="presentation" + > + handleToggleJob(job.id, job.enabled)} + aria-label={job.enabled ? 'Disable job' : 'Enable job'} + /> + + ), + }, + { + key: 'run', + header: '', + cellClassName: 'cron-cell-actions', + render: (job) => ( + e.stopPropagation()} role="presentation"> + + + ), + }, + ]} + />
)} - + +
+ Times shown in your local timezone · {VIEWER_TZ} +
+
)} + setDrawerJob(null)} + onRefresh={loadData} + onRun={handleRunJob} + onEdit={(j) => { setDrawerJob(null); openEditModal(j); }} + onDelete={handleDeleteJob} + onToggle={handleToggleJob} + /> + {/* Create/Edit Job Modal */} -
-
- - setJobForm({...jobForm, name: e.target.value})} - placeholder="My Backup Job" - required - /> -
+ +
+ + setJobForm({ ...jobForm, name: e.target.value })} + placeholder="My Backup Job" + required + /> +
-
- - setJobForm({...jobForm, command: e.target.value})} - placeholder="/usr/bin/backup.sh" - required - /> - The command or script to execute -
+
+ + setJobForm({ ...jobForm, command: e.target.value })} + placeholder="/usr/bin/backup.sh" + required + /> + The command or script to execute +
-
- -
+
+ +
- {jobForm.usePreset ? ( -
- - -
- ) : ( -
- - setJobForm({...jobForm, schedule: e.target.value})} - placeholder="0 0 * * *" - required={!jobForm.usePreset} - /> - - Format: minute hour day month weekday (e.g., "0 0 * * *" for daily at midnight) - -
- )} - -
- -