Visual redesign: design system, refined ops console aesthetic

- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.)
- Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors
- NavBar with logo, responsive hamburger menu, real logout
- All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings
- Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug
- Replaced alert()/confirm() with sonner toasts and accessible modals
- Added ErrorBoundary, skip link, accessible modal dialogs (Radix)
- Icons: lucide-react throughout, copy/download buttons
- 1.0.5 → 1.0.6
This commit is contained in:
2026-07-07 23:12:34 -04:00
parent 93c22e844e
commit 9cef7173cb
36 changed files with 4069 additions and 652 deletions
+371 -97
View File
@@ -1,18 +1,72 @@
import { useEffect, useState } from 'react';
import { api, Machine, SSHKey } 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 { Badge } from '@/components/ui/Badge';
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 { CopyButton } from '@/components/ui/CopyButton';
import { Card } from '@/components/ui/Card';
import { Pencil, Trash2, Plus, Server } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type MachineForm = {
id: number | undefined;
name: string;
host: string;
port: number;
ssh_user: string;
ssh_key_id: number | null;
mac_address: string;
wol_enabled: boolean;
wake_timeout_seconds: number;
wake_check_interval_seconds: number;
};
const defaultForm: MachineForm = {
id: undefined,
name: '',
host: '',
port: 22,
ssh_user: 'root',
ssh_key_id: null,
mac_address: '',
wol_enabled: false,
wake_timeout_seconds: 120,
wake_check_interval_seconds: 5,
};
export default function Machines() {
const [machines, setMachines] = useState<Machine[]>([]);
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
ssh_key_id: null as number | null,
mac_address: '', wol_enabled: false,
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
});
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<MachineForm>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try {
@@ -25,46 +79,78 @@ export default function Machines() {
} catch {}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const payload: Record<string, unknown> = {
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
ssh_user: form.ssh_user, ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) {
alert('Invalid MAC address format');
return;
}
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setShowForm(false);
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', ssh_key_id: null, mac_address: '', wol_enabled: false, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
load();
} catch (e: unknown) { alert((e as Error).message); }
function openCreate() {
setForm(defaultForm);
setModalOpen(true);
}
function edit(m: Machine) {
function openEdit(m: Machine) {
setForm({
id: m.id, name: m.name, host: m.host, port: m.port,
ssh_user: m.ssh_user, ssh_key_id: m.ssh_key_id,
id: m.id,
name: m.name,
host: m.host,
port: m.port,
ssh_user: m.ssh_user,
ssh_key_id: m.ssh_key_id,
mac_address: m.mac_address || '',
wol_enabled: m.wol_enabled,
wake_timeout_seconds: m.wake_timeout_seconds,
wake_check_interval_seconds: m.wake_check_interval_seconds,
});
setShowForm(true);
setModalOpen(true);
}
async function remove(id: number) {
if (!confirm('Delete machine?')) return;
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.host.trim()) {
toast.error('Name and host are required');
return;
}
if (
form.mac_address &&
!/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)
) {
toast.error('Invalid MAC address format (AA:BB:CC:DD:EE:FF)');
return;
}
setLoading(true);
try {
const payload: Record<string, unknown> = {
id: form.id || null,
name: form.name,
host: form.host,
port: Number(form.port),
ssh_user: form.ssh_user,
ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setModalOpen(false);
toast.success(form.id ? 'Machine updated' : 'Machine created');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/machines/${deleteId}`, { method: 'DELETE' });
toast.success('Machine deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function keyLabel(id: number | null) {
@@ -74,68 +160,256 @@ export default function Machines() {
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Machines</h1>
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
Add Machine
</button>
</div>
<div className="space-y-6">
<PageHeader
title="Machines"
description="Remote machines reachable via SSH"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</Button>
}
/>
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[480px] space-y-3">
<h2 className="text-lg font-bold">Machine</h2>
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<select value={form.ssh_key_id ?? ''} onChange={e => setForm({...form, ssh_key_id: e.target.value ? Number(e.target.value) : null})}
className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="">Server Key (default)</option>
{sshKeys.map(k => <option key={k.id} value={k.id}>{k.label} {k.in_use ? '(in use)' : ''}</option>)}
</select>
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<label className="flex items-center gap-2 text-gray-300">
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
Enable Wake-on-LAN
</label>
<div className="flex gap-2">
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
<button type="button" onClick={() => setShowForm(false)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
</div>
</form>
<Card>
<div className="p-0">
{machines.length === 0 ? (
<EmptyState
icon={<Server className="h-5 w-5" />}
title="No machines"
description="Add a remote machine to start syncing data"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Host</TableHead>
<TableHead>SSH Key</TableHead>
<TableHead>WoL</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{machines.map(m => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{m.host}:{m.port}
</span>
</TableCell>
<TableCell className="text-fg-muted text-xs">
{keyLabel(m.ssh_key_id)}
</TableCell>
<TableCell>
{m.wol_enabled ? (
<Badge variant="info" label="Yes" />
) : (
<span className="text-fg-subtle text-xs">No</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={m.status} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => openEdit(m)}
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(m.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>
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
<thead className="bg-gray-700">
<tr className="text-left text-gray-400">
<th className="p-3">Name</th>
<th className="p-3">Host</th>
<th className="p-3">SSH Key</th>
<th className="p-3">WoL</th>
<th className="p-3">Status</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
{machines.map(m => (
<tr key={m.id} className="border-t border-gray-700">
<td className="p-3 font-medium">{m.name}</td>
<td className="p-3">{m.host}:{m.port}</td>
<td className="p-3 text-gray-400 text-xs">{keyLabel(m.ssh_key_id)}</td>
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
<td className="p-3 text-gray-400">{m.status}</td>
<td className="p-3">
<button onClick={() => edit(m)} className="text-blue-400 hover:text-blue-300 mr-3">Edit</button>
<button onClick={() => remove(m.id)} className="text-red-400 hover:text-red-300">Delete</button>
</td>
</tr>
))}
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
</tbody>
</table>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>{form.id ? 'Edit Machine' : 'Add Machine'}</ModalTitle>
<ModalDescription>
{form.id
? 'Update the configuration for this machine'
: 'Configure a new remote machine for syncing'}
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name" required>
Name
</Label>
<Input
id="name"
placeholder="backup-nas"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="host">Host / IP</Label>
<Input
id="host"
placeholder="192.168.1.100"
value={form.host}
onChange={e => setForm({ ...form, host: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="port">SSH Port</Label>
<Input
id="port"
type="number"
placeholder="22"
value={form.port}
onChange={e =>
setForm({ ...form, port: Number(e.target.value) })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ssh_user">SSH User</Label>
<Input
id="ssh_user"
placeholder="root"
value={form.ssh_user}
onChange={e =>
setForm({ ...form, ssh_user: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh_key_id">SSH Key</Label>
<Select
value={form.ssh_key_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, ssh_key_id: v ? Number(v) : null })
}
>
<SelectTrigger id="ssh_key_id">
<SelectValue placeholder="Server Key" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Server Key (default)</SelectItem>
{sshKeys.map(k => (
<SelectItem key={k.id} value={k.id.toString()}>
{k.label} {k.in_use ? '(in use)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mac_address">MAC Address</Label>
<Input
id="mac_address"
placeholder="AA:BB:CC:DD:EE:FF"
value={form.mac_address}
onChange={e =>
setForm({ ...form, mac_address: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Required for Wake-on-LAN
</p>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-card p-3 transition-colors',
form.wol_enabled
? 'bg-accent/5 border border-accent/20'
: 'bg-surface-raised border border-border'
)}
>
<input
type="checkbox"
id="wol_enabled"
checked={form.wol_enabled}
onChange={e =>
setForm({ ...form, wol_enabled: e.target.checked })
}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="wol_enabled" className="cursor-pointer mb-0">
Enable Wake-on-LAN
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
{form.id ? 'Save Changes' : 'Add Machine'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Machine</ModalTitle>
<ModalDescription>
Are you sure you want to delete this machine? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === 'online'
? 'success'
: status === 'offline'
? 'neutral'
: 'info';
return <Badge variant={variant} label={status} />;
}