diff --git a/VERSION b/VERSION index 4f4b8896..049ac335 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.75 +1.7.76 diff --git a/docs/screenshots/cron-create.png b/docs/screenshots/cron-create.png new file mode 100644 index 00000000..92555255 Binary files /dev/null and b/docs/screenshots/cron-create.png differ diff --git a/docs/screenshots/cron-detail.png b/docs/screenshots/cron-detail.png new file mode 100644 index 00000000..6b070867 Binary files /dev/null and b/docs/screenshots/cron-detail.png differ diff --git a/docs/screenshots/cron.png b/docs/screenshots/cron.png index 974c2974..0ead168b 100644 Binary files a/docs/screenshots/cron.png and b/docs/screenshots/cron.png differ diff --git a/docs/screenshots/git.png b/docs/screenshots/git.png new file mode 100644 index 00000000..66457bb8 Binary files /dev/null and b/docs/screenshots/git.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 439eb7c0..a2f3b560 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/terminal-logs.png b/docs/screenshots/terminal-logs.png new file mode 100644 index 00000000..56ce5142 Binary files /dev/null and b/docs/screenshots/terminal-logs.png differ diff --git a/docs/screenshots/terminal-process-drawer.png b/docs/screenshots/terminal-process-drawer.png new file mode 100644 index 00000000..37a7a2d1 Binary files /dev/null and b/docs/screenshots/terminal-process-drawer.png differ diff --git a/frontend/src/components/dashboard/grid/WidgetEditor.jsx b/frontend/src/components/dashboard/grid/WidgetEditor.jsx index 5d5b2e45..e5b634ac 100644 --- a/frontend/src/components/dashboard/grid/WidgetEditor.jsx +++ b/frontend/src/components/dashboard/grid/WidgetEditor.jsx @@ -274,6 +274,7 @@ export function WidgetEditor({ return ( { if (!next) onClose?.(); }} title={title} diff --git a/frontend/src/components/dashboard/grid/WidgetLibrary.jsx b/frontend/src/components/dashboard/grid/WidgetLibrary.jsx index 112d51f0..506578c3 100644 --- a/frontend/src/components/dashboard/grid/WidgetLibrary.jsx +++ b/frontend/src/components/dashboard/grid/WidgetLibrary.jsx @@ -233,6 +233,7 @@ export function WidgetLibrary({ types = [], onAdd, onClose }) { return ( { if (!next) onClose?.(); }} title="Add a widget" diff --git a/frontend/src/components/databases/EngineCatalogDrawer.jsx b/frontend/src/components/databases/EngineCatalogDrawer.jsx index 8b902340..94c3c5af 100644 --- a/frontend/src/components/databases/EngineCatalogDrawer.jsx +++ b/frontend/src/components/databases/EngineCatalogDrawer.jsx @@ -174,6 +174,7 @@ export default function EngineCatalogDrawer({ return ( { if (!next) setResult(null); onOpenChange(next); }} title={result ? `${template.name} is installing` : `Install ${template.name}`} diff --git a/frontend/src/components/docker/ContainersTab.jsx b/frontend/src/components/docker/ContainersTab.jsx index e5df2734..7e6961ea 100644 --- a/frontend/src/components/docker/ContainersTab.jsx +++ b/frontend/src/components/docker/ContainersTab.jsx @@ -6,6 +6,7 @@ import LogToolbar from '../log-viewer/LogToolbar'; import LogContent from '../log-viewer/LogContent'; import EmptyState from '../EmptyState'; import Modal from '@/components/Modal'; +import { Drawer } from '@/components/ds'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -944,20 +945,17 @@ const ContainerLogsModal = ({ container, onClose }) => { } return ( - <> -
- - + ); }; diff --git a/frontend/src/components/ds/Drawer.jsx b/frontend/src/components/ds/Drawer.jsx index 5e31c7b4..d4130a6d 100644 --- a/frontend/src/components/ds/Drawer.jsx +++ b/frontend/src/components/ds/Drawer.jsx @@ -10,6 +10,19 @@ import { cn } from '@/lib/utils'; // subtitle="server filesystem" icon={} width={760}> // …body… // +// +// The body is PADDED by default, to the same 18px inset as the header — a +// drawer that forgets to pad itself used to render every field flush against +// the panel edge, and enough of them forgot that the drawers stopped looking +// like one component. Pass `flush` for bodies that own their own regions +// (a form with a pinned footer, a two-pane catalog, an editor, a log tail): +// +// +//
← pads its own scroll region +//
+//
+//
+//
export function Drawer({ open, onOpenChange, @@ -21,6 +34,8 @@ export function Drawer({ side = 'right', headerExtra, className, + bodyClassName, + flush = false, children, }) { return ( @@ -39,7 +54,9 @@ export function Drawer({
{headerExtra} -
{children}
+
+ {children} +
); diff --git a/frontend/src/layouts/PageLayout.jsx b/frontend/src/layouts/PageLayout.jsx new file mode 100644 index 00000000..7766a21c --- /dev/null +++ b/frontend/src/layouts/PageLayout.jsx @@ -0,0 +1,55 @@ +import { cn } from '@/lib/utils'; +import { PageTopbar } from '@/components/ds'; + +// Shell for a STANDALONE page — one that owns its whole route rather than +// living inside a PageTopbar tab group. It is the exact same frame +// TabGroupLayout gives a group (see layouts/TabGroupLayout.jsx): the top bar +// pins flush to the top of the content region edge-to-edge, and the body +// scrolls beneath it, centered at the reading max-width. +// +// } title="Cron Jobs" actions={…}> +// …page body… +// +// +// Rendering a PageTopbar inside the default padded `.page-container` instead +// (what every standalone page used to do by hand) leaves the bar floating in +// the middle of the content well with a gutter on every side — it carries the +// sidebar's surface and a bottom border precisely because it is meant to be +// chrome pinned to the frame, not the first row of the page body. +// +// `fill` hands the body the whole region to manage its own scrolling (log +// consoles, file trees, terminals); the default centers and pads it. +export default function PageLayout({ + icon, + title, + meta, + tabs, + navLabel, + actions, + fill = false, + className, + topbarClassName, + contentClassName, + children, +}) { + return ( +
+ +
+ {fill ? ( +
{children}
+ ) : ( +
{children}
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/AppMap.jsx b/frontend/src/pages/AppMap.jsx index 9fe1d0c1..a6783ce2 100644 --- a/frontend/src/pages/AppMap.jsx +++ b/frontend/src/pages/AppMap.jsx @@ -12,7 +12,7 @@ import { Map as MapIcon, Layers, Compass, Network, Info, } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import useTabParam from '../hooks/useTabParam'; const VIEWS = [ @@ -284,9 +284,7 @@ export default function AppMap() { const view = VIEWS.find(v => v.id === activeView) || VIEWS[0]; return ( -
- } title="App Map" /> - + } title="App Map"> {VIEWS.map(v => ( @@ -329,6 +327,6 @@ export default function AppMap() {
- + ); } diff --git a/frontend/src/pages/CloudflareZoneSettings.jsx b/frontend/src/pages/CloudflareZoneSettings.jsx index 757d6e49..0fb61900 100644 --- a/frontend/src/pages/CloudflareZoneSettings.jsx +++ b/frontend/src/pages/CloudflareZoneSettings.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { Cloud, ShieldCheck, Lock, Gauge, Database, Wand2, Eraser, Flame, Zap, Network, HardDrive } from 'lucide-react'; -import { PageTopbar } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import CloudflareWafPanel from '../components/cloudflare/CloudflareWafPanel'; import WorkersPanel from '../components/cloudflare/WorkersPanel'; import TunnelsPanel from '../components/cloudflare/TunnelsPanel'; @@ -153,8 +153,12 @@ const CloudflareZoneSettings = () => { if (error) { return ( -
- } title={crumbs} /> + } + title={crumbs} + >
{
- + ); } @@ -174,13 +178,13 @@ const CloudflareZoneSettings = () => { const firstTab = tabGroups[0]?.key || 'actions'; return ( -
- } - title={crumbs} - meta={zone?.provider_zone_id ? `Zone ${zone.provider_zone_id}` : undefined} - /> - + } + title={crumbs} + meta={zone?.provider_zone_id ? `Zone ${zone.provider_zone_id}` : undefined} + >
@@ -316,7 +320,7 @@ const CloudflareZoneSettings = () => { variant="danger" /> )} -
+
); }; diff --git a/frontend/src/pages/Coexistence.jsx b/frontend/src/pages/Coexistence.jsx index f0c885ab..8b60a898 100644 --- a/frontend/src/pages/Coexistence.jsx +++ b/frontend/src/pages/Coexistence.jsx @@ -4,7 +4,8 @@ import { Eye, ArrowRightLeft, ServerCog, ShieldCheck, RadioTower, Upload, RefreshCw, ArrowRight, } from 'lucide-react'; -import { PageTopbar, Pill } from '@/components/ds'; +import { Pill } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import { Button } from '@/components/ui/button'; import EmptyState from '@/components/EmptyState'; import api from '../services/api'; @@ -91,19 +92,18 @@ export default function Coexistence() { const observed = servers.filter(isObserved); return ( -
- } - title="Coexistence" - meta="Running alongside another panel" - actions={ - - } - /> - + } + title="Coexistence" + meta="Running alongside another panel" + actions={ + + } + >

ServerKit has three explicit adoption modes. Pick the one that matches where the box is today — you can move between them as you migrate. The hard rule: keep exactly @@ -212,6 +212,6 @@ export default function Coexistence() {

- + ); } diff --git a/frontend/src/pages/CronJobs.jsx b/frontend/src/pages/CronJobs.jsx index a9b5f177..c04c0b92 100644 --- a/frontend/src/pages/CronJobs.jsx +++ b/frontend/src/pages/CronJobs.jsx @@ -9,12 +9,21 @@ import { Clock, Plus, RefreshCw, Play, Pause, Trash2, Pencil, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; -import { Pill, SegControl, SearchField, PageTopbar, DataTable, Drawer } from '@/components/ds'; +import { Pill, SegControl, SearchField, DataTable, Drawer } from '@/components/ds'; +import SchedulePicker from '../components/SchedulePicker'; +import PageLayout from '../layouts/PageLayout'; + +// A new job opens on a sane, non-destructive cadence rather than an empty +// expression the picker would have to render as invalid. +const DEFAULT_SCHEDULE = '0 3 * * *'; + +const EMPTY_JOB_FORM = { + name: '', + command: '', + schedule: DEFAULT_SCHEDULE, + description: '', +}; // Fallback humanizer for the handful of schedules the backend can't describe. const SCHEDULE_LABELS = { @@ -81,7 +90,6 @@ const CronJobs = () => { const { confirm } = useConfirm(); const [status, setStatus] = useState(null); const [jobs, setJobs] = useState([]); - const [presets, setPresets] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -91,32 +99,20 @@ const CronJobs = () => { // Drawer + modal states const [drawerJob, setDrawerJob] = useState(null); - const [showJobModal, setShowJobModal] = useState(false); + const [showJobDrawer, setShowJobDrawer] = useState(false); const [editingJob, setEditingJob] = useState(null); const [runningJobId, setRunningJobId] = useState(null); const [runOutput, setRunOutput] = useState(null); - // Form state - const [jobForm, setJobForm] = useState({ - name: '', - command: '', - schedule: '', - description: '', - usePreset: true, - preset: 'daily', - }); - const loadData = useCallback(async () => { try { setLoading(true); - const [statusRes, jobsRes, presetsRes] = await Promise.all([ + const [statusRes, jobsRes] = await Promise.all([ api.getCronStatus(), api.getCronJobs(), - api.getCronPresets(), ]); setStatus(statusRes); setJobs(jobsRes.jobs || []); - setPresets(presetsRes.presets || {}); } catch (err) { setError(err.message); } finally { @@ -128,68 +124,19 @@ const CronJobs = () => { loadData(); }, [loadData]); - const resetForm = () => { - setJobForm({ - name: '', - command: '', - schedule: '', - description: '', - usePreset: true, - preset: 'daily', - }); - }; - - const openCreateModal = () => { + const openCreateDrawer = () => { setEditingJob(null); - resetForm(); - setShowJobModal(true); + setShowJobDrawer(true); }; - const openEditModal = (job) => { + const openEditDrawer = (job) => { setEditingJob(job); - const presetKey = Object.entries(presets).find(([, v]) => v === job.schedule)?.[0]; - setJobForm({ - name: job.name || '', - command: job.command || '', - schedule: job.schedule || '', - description: job.description || '', - usePreset: !!presetKey, - preset: presetKey || 'daily', - }); - setShowJobModal(true); + setShowJobDrawer(true); }; - const closeJobModal = () => { - setShowJobModal(false); + const closeJobDrawer = () => { + setShowJobDrawer(false); setEditingJob(null); - resetForm(); - }; - - const handleSubmitJob = async (e) => { - e.preventDefault(); - try { - const schedule = jobForm.usePreset ? presets[jobForm.preset] : jobForm.schedule; - const payload = { - name: jobForm.name, - command: jobForm.command, - schedule, - description: jobForm.description, - }; - - if (editingJob) { - await api.updateCronJob(editingJob.id, payload); - toast.success('Cron job updated successfully'); - } else { - await api.createCronJob(payload); - toast.success('Cron job created successfully'); - } - - closeJobModal(); - setDrawerJob(null); - loadData(); - } catch (err) { - toast.error(err.message); - } }; const handleDeleteJob = async (jobId) => { @@ -272,27 +219,26 @@ const CronJobs = () => { : (status?.type === 'serverkit_scheduler' ? 'internal scheduler' : null); return ( -
- } - title="Cron Jobs" - actions={( - <> - - - - - )} - /> - + } + title="Cron Jobs" + actions={( + <> + + + + + )} + > {error && (
{error} @@ -314,15 +260,21 @@ const CronJobs = () => { icon={Clock} title="No cron jobs" description="No scheduled jobs found. Create your first cron job to automate tasks." - action={} + action={} /> ) : (
{/* No KPI strip: every number it carried is already on the segment you'd click to act on it (mirrors /domains). */}
- - {serviceNote && {serviceNote}} +

All jobs

+
+ {serviceNote && {serviceNote}} + + {shown.length} of {jobs.length} jobs + + +
{shown.length === 0 ? ( @@ -447,101 +399,17 @@ const CronJobs = () => { onClose={() => setDrawerJob(null)} onRefresh={loadData} onRun={handleRunJob} - onEdit={(j) => { setDrawerJob(null); openEditModal(j); }} + onEdit={(j) => { setDrawerJob(null); openEditDrawer(j); }} onDelete={handleDeleteJob} onToggle={handleToggleJob} /> - {/* Create/Edit Job Modal */} - -
-
- - 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 -
- -
- -
- - {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) - -
- )} - -
- -