Phase A-E: stability, security, observability, and test coverage
Phase A - Stability: - Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash - Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits - Queue keyed by jobID (not syncPairID): cancel now targets exact job - Local rsync uses jobCtx (context.Background() replaced) - Migrations wrapped in transactions; checksums stored Phase B - Security: - admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run - Path validation: rejects .., leading -, null bytes in sync pair paths - Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from) - Shell concat in RunRemote replaced with proper sh -c escaping - knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts - RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role - deploy-keys: uses authorized_keys only (no private key upload) - Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir() Phase C - Operational: - /readyz health check: DB query + SSH dir accessibility - /metrics endpoint: Prometheus text format (jobs, queue, machines) - Event struct JSON tags: job_id, machine_id, type (snake_case) - EventBus broadcast: fanned out to all subscribers - SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set - Filesystem job log cleanup: removes .log files for purged jobs - Backup retention: old backups auto-purged Phase D - Frontend: - Schedules page: REST API + full CRUD UI for cron schedules - Dashboard: cancel button for running/queued jobs - JobDetail: server-side log download via API - Settings: displays data_dir from server - 404 page: proper NotFound component Phase E - Tests: - auth_test.go: JWT, bcrypt, middleware, seed (18 tests) - models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests) - go test -race: no data races found
This commit is contained in:
+22
-1
@@ -6,6 +6,7 @@ import {
|
||||
NavLink,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
Link,
|
||||
} from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { Spinner } from './components/ui/Spinner';
|
||||
@@ -34,11 +37,13 @@ import JobHistory from './pages/JobHistory';
|
||||
import JobDetail from './pages/JobDetail';
|
||||
import SettingsPage from './pages/Settings';
|
||||
import SSHKeys from './pages/SSHKeys';
|
||||
import Schedules from './pages/Schedules';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/machines', label: 'Machines', icon: Server },
|
||||
{ to: '/sync-pairs', label: 'Sync Pairs', icon: GitCompare },
|
||||
{ to: '/schedules', label: 'Schedules', icon: Clock },
|
||||
{ to: '/jobs', label: 'Jobs', icon: History },
|
||||
{ to: '/ssh-keys', label: 'SSH Keys', icon: Key },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
|
||||
@@ -217,6 +222,21 @@ function Layout() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
|
||||
<p className="text-2xl font-bold text-fg">404</p>
|
||||
<p className="text-fg-muted">Page not found</p>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to="/">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -233,12 +253,13 @@ export default function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/machines" element={<Machines />} />
|
||||
<Route path="/sync-pairs" element={<SyncPairs />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/jobs" element={<JobHistory />} />
|
||||
<Route path="/jobs/:id" element={<JobDetail />} />
|
||||
<Route path="/ssh-keys" element={<SSHKeys />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -143,6 +143,16 @@ export interface ShutdownResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
sync_pair_id: number;
|
||||
sync_pair_name: string;
|
||||
cron_expr: string;
|
||||
next_run_at: string | null;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function shutdownMachine(machineId: number): Promise<ShutdownResponse> {
|
||||
return api<ShutdownResponse>(`/api/machines/${machineId}/shutdown`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
|
||||
import { Server, Activity, HardDrive, Clock, Plus, XCircle } from 'lucide-react';
|
||||
import { api, Machine, Job, SyncPair } from '../api/client';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { subscribeMachineStatus } from '@/lib/sse';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
@@ -48,6 +49,17 @@ export default function Dashboard() {
|
||||
|
||||
const pairName = (id: number) => pairs.find(p => p.id === id)?.name ?? `Pair ${id}`;
|
||||
|
||||
async function cancelJob(jobId: number) {
|
||||
try {
|
||||
await api(`/api/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||
toast.success('Job cancelled');
|
||||
const j = await api<Job[]>(`/api/jobs?limit=5`);
|
||||
setJobs(j);
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const online = machines.filter(m => m.status.startsWith('online')).length;
|
||||
const todayJobs = jobs.filter(j => {
|
||||
if (!j.started_at) return false;
|
||||
@@ -156,6 +168,7 @@ export default function Dashboard() {
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead className="w-16">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -178,6 +191,19 @@ export default function Dashboard() {
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.started_at ? formatRelativeTime(j.started_at) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => cancelJob(j.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Cancel job"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
+32
-11
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { api, apiRaw } from '../api/client';
|
||||
import type { Job, LogLine, SyncPair } from '../api/client';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -37,12 +37,12 @@ import { formatDuration } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SSEProgress {
|
||||
fileBytes: number;
|
||||
file_bytes: number;
|
||||
pct: number;
|
||||
speedBps: number;
|
||||
etaSeconds: number;
|
||||
xfrDone: number;
|
||||
xfrTotal: number;
|
||||
speed_bps: number;
|
||||
eta_seconds: number;
|
||||
xfr_done: number;
|
||||
xfr_total: number;
|
||||
}
|
||||
|
||||
interface SSEEvent {
|
||||
@@ -157,12 +157,33 @@ export default function JobDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadLog() {
|
||||
async function downloadLog() {
|
||||
try {
|
||||
const resp = await apiRaw(`/api/jobs/${id}/log/download`);
|
||||
if (resp.ok && resp.body) {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `job-${id}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
fallbackDownload();
|
||||
}
|
||||
} catch {
|
||||
fallbackDownload();
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackDownload() {
|
||||
const allLines = [
|
||||
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
|
||||
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
|
||||
];
|
||||
const blob = new Blob([allLines.join('\n')], { type: 'text/plain' });
|
||||
const blob = new Blob(allLines as string[], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -553,13 +574,13 @@ function TransferProgress({
|
||||
|
||||
<div className="flex justify-between text-xs text-fg-muted">
|
||||
<span className="font-mono">
|
||||
xfr#{(progress.xfrDone).toLocaleString()}/{progress.xfrTotal > 0 ? progress.xfrTotal.toLocaleString() : '?'}
|
||||
xfr#{(progress.xfr_done).toLocaleString()}/{progress.xfr_total > 0 ? progress.xfr_total.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{formatSpeed(progress.speedBps)}
|
||||
{formatSpeed(progress.speed_bps)}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
ETA {progress.etaSeconds > 0 ? `${Math.floor(progress.etaSeconds / 60)}m ${progress.etaSeconds % 60}s` : '-'}
|
||||
ETA {progress.eta_seconds > 0 ? `${Math.floor(progress.eta_seconds / 60)}m ${progress.eta_seconds % 60}s` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, SyncPair, Schedule } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from '@/components/ui/Modal';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Trash2, Plus, Clock, Pencil, AlertTriangle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScheduleForm = {
|
||||
id: number | undefined;
|
||||
sync_pair_id: number | null;
|
||||
cron_expr: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const defaultForm: ScheduleForm = {
|
||||
id: undefined,
|
||||
sync_pair_id: null,
|
||||
cron_expr: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function Schedules() {
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [syncPairs, setSyncPairs] = useState<SyncPair[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<ScheduleForm>(defaultForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [s, p] = await Promise.all([
|
||||
api<Schedule[]>('/api/schedules'),
|
||||
api<SyncPair[]>('/api/sync-pairs'),
|
||||
]);
|
||||
setSchedules(s);
|
||||
setSyncPairs(p);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setForm(defaultForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.sync_pair_id) {
|
||||
toast.error('Sync pair is required');
|
||||
return;
|
||||
}
|
||||
if (!form.cron_expr.trim()) {
|
||||
toast.error('Cron expression is required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api(form.id ? `/api/schedules/${form.id}` : '/api/schedules', {
|
||||
method: form.id ? 'PUT' : 'POST',
|
||||
body: {
|
||||
sync_pair_id: form.sync_pair_id,
|
||||
cron_expr: form.cron_expr,
|
||||
enabled: form.enabled,
|
||||
},
|
||||
});
|
||||
setModalOpen(false);
|
||||
toast.success(form.id ? 'Schedule updated' : 'Schedule created');
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(schedule: Schedule) {
|
||||
try {
|
||||
await api(`/api/schedules/${schedule.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
cron_expr: schedule.cron_expr,
|
||||
enabled: !schedule.enabled,
|
||||
},
|
||||
});
|
||||
toast.success(`Schedule ${schedule.enabled ? 'disabled' : 'enabled'}`);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (deleteId === null) return;
|
||||
try {
|
||||
await api(`/api/schedules/${deleteId}`, { method: 'DELETE' });
|
||||
toast.success('Schedule deleted');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPairName(id: number) {
|
||||
const p = syncPairs.find(p => p.id === id);
|
||||
return p ? p.name : `Sync Pair ${id}`;
|
||||
}
|
||||
|
||||
function formatNextRun(nextRun: string | null) {
|
||||
if (!nextRun) return 'Not scheduled';
|
||||
const d = new Date(nextRun);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Schedules"
|
||||
description="Automate sync pair execution with cron-based scheduling"
|
||||
actions={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Schedule
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="p-0">
|
||||
{schedules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
title="No schedules"
|
||||
description="Create a schedule to automate sync pair execution"
|
||||
action={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Schedule
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Cron Expression</TableHead>
|
||||
<TableHead>Next Run</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedules.map(s => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{syncPairName(s.sync_pair_id)}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-surface-raised px-2 py-1 rounded font-mono">
|
||||
{s.cron_expr}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-sm">
|
||||
{formatNextRun(s.next_run_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(s)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-canvas',
|
||||
s.enabled ? 'bg-emerald-500/20' : 'bg-surface-raised'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-3.5 w-3.5 transform rounded-full bg-fg-muted transition-transform',
|
||||
s.enabled ? 'translate-x-4 bg-emerald-400' : 'translate-x-1 bg-fg-subtle'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setDeleteId(s.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ModalContent size="md">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Add Schedule</ModalTitle>
|
||||
<ModalDescription>
|
||||
Schedule automated sync pair execution using cron syntax.
|
||||
Format: "minute hour day-of-month month day-of-week"
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalBody className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sch-sync-pair" required>
|
||||
Sync Pair
|
||||
</Label>
|
||||
<Select
|
||||
value={form.sync_pair_id?.toString() ?? ''}
|
||||
onValueChange={v => setForm({ ...form, sync_pair_id: Number(v) })}
|
||||
>
|
||||
<SelectTrigger id="sch-sync-pair">
|
||||
<SelectValue placeholder="Select a sync pair" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{syncPairs.map(p => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sch-cron" required>
|
||||
Cron Expression
|
||||
</Label>
|
||||
<Input
|
||||
id="sch-cron"
|
||||
placeholder="0 2 * * *"
|
||||
value={form.cron_expr}
|
||||
onChange={e => setForm({ ...form, cron_expr: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Format: "m h dom mon dow" (5 fields, no seconds)
|
||||
<br />
|
||||
Examples: "0 2 * * *" (daily at 2am), "0 */6 * * *" (every 6 hours)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-card p-3 transition-colors',
|
||||
form.enabled
|
||||
? 'bg-accent/5 border border-accent/20'
|
||||
: 'bg-surface-raised border border-border'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sch-enabled"
|
||||
checked={form.enabled}
|
||||
onChange={e => setForm({ ...form, enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<Label htmlFor="sch-enabled" className="cursor-pointer mb-0">
|
||||
Enable this schedule
|
||||
</Label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
Add Schedule
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
|
||||
<ModalContent size="sm">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Delete Schedule</ModalTitle>
|
||||
<ModalDescription>
|
||||
Are you sure you want to delete this schedule?
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,21 @@ import { CopyButton } from '@/components/ui/CopyButton';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardHeader, CardTitle, CardBody } from '@/components/ui/Card';
|
||||
import { Key, Download, Terminal } from 'lucide-react';
|
||||
import { Key, Download, Terminal, HardDrive } from 'lucide-react';
|
||||
import { api, SettingsInfo } from '../api/client';
|
||||
|
||||
export default function Settings() {
|
||||
const [pubKey, setPubKey] = useState('');
|
||||
const [dataDir, setDataDir] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/pubkey', { credentials: 'include' })
|
||||
.then(r => (r.ok ? r.text() : ''))
|
||||
.then(t => setPubKey(t))
|
||||
Promise.all([
|
||||
fetch('/api/settings/pubkey', { credentials: 'include' })
|
||||
.then(r => (r.ok ? r.text() : ''))
|
||||
.then(t => setPubKey(t)),
|
||||
api<SettingsInfo>('/api/settings/info').then(info => setDataDir(info.data_dir)),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -75,6 +80,20 @@ export default function Settings() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-card bg-accent/10 p-1">
|
||||
<HardDrive className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<CardTitle>Data Directory</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="text-sm font-mono text-fg">{dataDir || '-'}</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user