631 lines
23 KiB
TypeScript
631 lines
23 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { api, Machine, SSHKey, TestConnectionResponse } 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, Zap, Cable } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { cn } from '@/lib/utils';
|
|
import { subscribeMachineStatus } from '@/lib/sse';
|
|
|
|
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;
|
|
broadcast_addr: string;
|
|
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,
|
|
broadcast_addr: '',
|
|
wake_timeout_seconds: 180,
|
|
wake_check_interval_seconds: 5,
|
|
};
|
|
|
|
export default function Machines() {
|
|
const [machines, setMachines] = useState<Machine[]>([]);
|
|
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
const [form, setForm] = useState<MachineForm>(defaultForm);
|
|
const [loading, setLoading] = useState(false);
|
|
const [probing, setProbing] = useState(false);
|
|
const [connModal, setConnModal] = useState<{ machine: Machine | null; result: TestConnectionResponse | null; loading: boolean }>({ machine: null, result: null, loading: false });
|
|
|
|
useEffect(() => {
|
|
load();
|
|
setProbing(true);
|
|
api('/api/machines/refresh', { method: 'POST' }).catch(() => {});
|
|
const timer = setTimeout(() => setProbing(false), 5000);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const unsub = subscribeMachineStatus((evt) => {
|
|
setMachines((prev) =>
|
|
prev.map((m) =>
|
|
m.id === evt.machine_id ? { ...m, status: evt.status } : m
|
|
)
|
|
);
|
|
});
|
|
return unsub;
|
|
}, []);
|
|
|
|
async function load() {
|
|
try {
|
|
const [ms, ks] = await Promise.all([
|
|
api<Machine[]>('/api/machines'),
|
|
api<SSHKey[]>('/api/ssh-keys'),
|
|
]);
|
|
setMachines(ms);
|
|
setSSHKeys(ks);
|
|
} catch {}
|
|
}
|
|
|
|
function openCreate() {
|
|
setForm(defaultForm);
|
|
setModalOpen(true);
|
|
}
|
|
|
|
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,
|
|
mac_address: m.mac_address || '',
|
|
wol_enabled: m.wol_enabled,
|
|
broadcast_addr: m.broadcast_addr || '',
|
|
wake_timeout_seconds: m.wake_timeout_seconds || 180,
|
|
wake_check_interval_seconds: m.wake_check_interval_seconds || 5,
|
|
});
|
|
setModalOpen(true);
|
|
}
|
|
|
|
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),
|
|
broadcast_addr: form.broadcast_addr || null,
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function handleTestWol(m: Machine) {
|
|
try {
|
|
await api<{ ok: boolean; sent: number }>(`/api/machines/${m.id}/test-wol`, { method: 'POST' });
|
|
toast.success(`Magic packet sent (${m.mac_address})`);
|
|
} catch (e: unknown) {
|
|
toast.error(`Wake failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function handleTestConnection(m: Machine) {
|
|
setConnModal({ machine: m, result: null, loading: true });
|
|
try {
|
|
const result = await api<TestConnectionResponse>(`/api/machines/${m.id}/test-connection`, { method: 'POST' });
|
|
setConnModal({ machine: m, result, loading: false });
|
|
} catch (e: unknown) {
|
|
setConnModal({ machine: m, result: { success: false, error: (e as Error).message }, loading: false });
|
|
}
|
|
}
|
|
|
|
async function handleApproveFingerprint() {
|
|
if (!connModal.machine || !connModal.result?.fingerprint) return;
|
|
try {
|
|
const updated = await api<Machine>(`/api/machines/${connModal.machine.id}/approve-fingerprint`, {
|
|
method: 'POST',
|
|
body: { fingerprint: connModal.result.fingerprint },
|
|
});
|
|
setMachines(prev => prev.map(m => m.id === updated.id ? updated : m));
|
|
toast.success('Fingerprint approved');
|
|
setConnModal({ machine: null, result: null, loading: false });
|
|
} catch (e: unknown) {
|
|
toast.error(`Approve failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
function closeConnModal() {
|
|
setConnModal({ machine: null, result: null, loading: false });
|
|
}
|
|
|
|
function keyLabel(id: number | null) {
|
|
if (!id) return 'Server Key';
|
|
const k = sshKeys.find(k => k.id === id);
|
|
return k ? k.label : `Key #${id}`;
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
}
|
|
/>
|
|
|
|
<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>
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
{probing && (
|
|
<div className="flex items-center gap-2 px-4 pt-3 text-xs text-muted-foreground">
|
|
<span className="inline-block h-2 w-2 bg-amber-400 rounded-full animate-pulse" />
|
|
Checking machine status...
|
|
</div>
|
|
)}
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Host</TableHead>
|
|
<TableHead>SSH Key</TableHead>
|
|
<TableHead>WoL</TableHead>
|
|
<TableHead>WoL Timeout</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>
|
|
{m.wol_enabled ? (
|
|
<span className="text-xs text-fg-muted">
|
|
{m.wake_timeout_seconds}s
|
|
</span>
|
|
) : (
|
|
<span className="text-fg-subtle text-xs">—</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={() => handleTestConnection(m)}
|
|
title="Test SSH Connection"
|
|
>
|
|
<Cable className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{m.wol_enabled && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => handleTestWol(m)}
|
|
title="Test Wake-on-LAN"
|
|
>
|
|
<Zap 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>
|
|
|
|
<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>
|
|
{form.wol_enabled && (
|
|
<div className="space-y-3 p-3 border border-border rounded-card bg-surface-raised">
|
|
<p className="text-xs text-fg-subtle font-medium">Wake-on-LAN Settings</p>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="broadcast_addr">Broadcast Address</Label>
|
|
<Input
|
|
id="broadcast_addr"
|
|
placeholder="255.255.255.255"
|
|
value={form.broadcast_addr}
|
|
onChange={e =>
|
|
setForm({ ...form, broadcast_addr: e.target.value })
|
|
}
|
|
/>
|
|
<p className="text-xs text-fg-subtle">
|
|
Leave blank to use 255.255.255.255. Set to your subnet broadcast
|
|
address if the server has multiple interfaces.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wake_timeout_seconds">Wake timeout (s)</Label>
|
|
<Input
|
|
id="wake_timeout_seconds"
|
|
type="number"
|
|
min={10}
|
|
max={600}
|
|
value={form.wake_timeout_seconds}
|
|
onChange={e =>
|
|
setForm({ ...form, wake_timeout_seconds: Number(e.target.value) })
|
|
}
|
|
/>
|
|
<p className="text-xs text-fg-subtle">
|
|
HP Microservers typically need ~180s.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="wake_check_interval_seconds">Check interval (s)</Label>
|
|
<Input
|
|
id="wake_check_interval_seconds"
|
|
type="number"
|
|
min={1}
|
|
max={60}
|
|
value={form.wake_check_interval_seconds}
|
|
onChange={e =>
|
|
setForm({ ...form, wake_check_interval_seconds: Number(e.target.value) })
|
|
}
|
|
/>
|
|
<p className="text-xs text-fg-subtle">
|
|
How often to poll SSH readiness.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<Modal open={connModal.machine !== null} onOpenChange={v => !v && closeConnModal()}>
|
|
<ModalContent size="md">
|
|
<ModalHeader>
|
|
<ModalTitle>SSH Connection Test</ModalTitle>
|
|
<ModalDescription>
|
|
{connModal.machine?.name} ({connModal.machine?.host}:{connModal.machine?.port})
|
|
</ModalDescription>
|
|
</ModalHeader>
|
|
<ModalBody className="space-y-4">
|
|
{connModal.loading && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
{!connModal.loading && connModal.result && (
|
|
<div className="space-y-4">
|
|
{connModal.result.success ? (
|
|
<div className="rounded-card bg-emerald-500/10 border border-emerald-500/30 p-4">
|
|
<p className="text-sm font-medium text-emerald-400 mb-1">Connection successful</p>
|
|
<pre className="text-xs text-fg-muted whitespace-pre-wrap">{connModal.result.output}</pre>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-card bg-rose-500/10 border border-rose-500/30 p-4">
|
|
<p className="text-sm font-medium text-rose-400 mb-1">Connection failed</p>
|
|
<p className="text-xs text-fg-muted">{connModal.result.error}</p>
|
|
</div>
|
|
)}
|
|
{connModal.result.fingerprint && (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-fg">Host Key Fingerprint</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs font-mono bg-surface-raised rounded-card px-3 py-2 text-fg-muted border border-border">
|
|
{connModal.result.fingerprint}
|
|
</code>
|
|
<CopyButton text={connModal.result.fingerprint} />
|
|
</div>
|
|
{connModal.machine && !connModal.machine.fingerprint_confirmed && (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant="pending" label="Not verified" />
|
|
<span className="text-xs text-fg-muted">Approve to trust this fingerprint</span>
|
|
</div>
|
|
)}
|
|
{connModal.machine && connModal.machine.fingerprint_confirmed && (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant="success" label="Verified" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="secondary" onClick={closeConnModal}>
|
|
Close
|
|
</Button>
|
|
{!connModal.loading && connModal.result && connModal.result.fingerprint && connModal.machine && !connModal.machine.fingerprint_confirmed && (
|
|
<Button onClick={handleApproveFingerprint}>
|
|
Approve & Trust Fingerprint
|
|
</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} />;
|
|
}
|