This commit is contained in:
@@ -395,7 +395,7 @@ export default function KeyDetailPage() {
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{assignment.server?.ip_address ?? "—"}
|
||||
{assignment.server?.ip_address ?? "n/a"}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
@@ -412,7 +412,7 @@ export default function KeyDetailPage() {
|
||||
<span className="text-text-secondary text-xs">
|
||||
{assignment.revoked_at
|
||||
? new Date(assignment.revoked_at).toLocaleDateString()
|
||||
: "—"}
|
||||
: "n/a"}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
|
||||
+174
-186
@@ -8,202 +8,190 @@ import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function UploadKeyModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [label, setLabel] = useState("");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const [label, setLabel] = useState("");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
|
||||
const { mutate: upload, isPending, error } = useMutation({
|
||||
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutate: upload,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Upload SSH Key</h2>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Upload SSH Key</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. dom-macbook"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Public Key
|
||||
</label>
|
||||
<textarea
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Private Key{" "}
|
||||
<span className="text-text-tertiary font-normal">(optional — stored AES-256-GCM encrypted)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase{" "}
|
||||
<span className="text-text-tertiary font-normal">(optional — for an encrypted private key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. dom-macbook"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Public Key</label>
|
||||
<textarea
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Private Key <span className="text-text-tertiary font-normal">(optional stored AES-256-GCM encrypted)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary font-normal">(optional for an encrypted private key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isPending} disabled={!label.trim() || !publicKey.trim()} onClick={() => upload()}>
|
||||
Upload Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPending}
|
||||
disabled={!label.trim() || !publicKey.trim()}
|
||||
onClick={() => upload()}
|
||||
>
|
||||
Upload Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeysPage() {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
const { data: keys, isLoading, error } = useQuery({
|
||||
queryKey: ["keys"],
|
||||
queryFn: api.listKeys,
|
||||
});
|
||||
const {
|
||||
data: keys,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["keys"],
|
||||
queryFn: api.listKeys,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showUpload && <UploadKeyModal onClose={() => setShowUpload(false)} />}
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showUpload && <UploadKeyModal onClose={() => setShowUpload(false)} />}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">SSH Keys</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{keys?.length ?? 0} key{keys?.length !== 1 ? "s" : ""} managed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowUpload(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
Upload Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">
|
||||
Failed to load keys. Is the backend running?
|
||||
</div>
|
||||
) : keys && keys.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Assignments</Th>
|
||||
<Th>Created</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{keys.map((key: Key) => (
|
||||
<Tr key={key.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium text-text-primary">{key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{key.fingerprint}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>
|
||||
{key.source}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary">
|
||||
{key.assigned_count ?? 0} server{(key.assigned_count ?? 0) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">
|
||||
{new Date(key.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${key.key_id}`}>
|
||||
<Button variant="ghost" size="sm">View →</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
|
||||
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">SSH Keys</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{keys?.length ?? 0} key{keys?.length !== 1 ? "s" : ""} managed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowUpload(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
Upload Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-secondary">No SSH keys yet.</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => setShowUpload(true)}
|
||||
>
|
||||
Upload your first key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load keys. Is the backend running?</div>
|
||||
) : keys && keys.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Assignments</Th>
|
||||
<Th>Created</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{keys.map((key: Key) => (
|
||||
<Tr key={key.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium text-text-primary">{key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>{key.source}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary">
|
||||
{key.assigned_count ?? 0} server{(key.assigned_count ?? 0) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${key.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View →
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
|
||||
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No SSH keys yet.</p>
|
||||
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowUpload(true)}>
|
||||
Upload your first key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,232 +8,224 @@ import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function statusVariant(status: MonitorStatus) {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function uptimePct(rollups: Rollup[]): number {
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
const up = rollups.reduce((a, r) => a + r.up_count, 0);
|
||||
return checks > 0 ? (up / checks) * 100 : 0;
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
const up = rollups.reduce((a, r) => a + r.up_count, 0);
|
||||
return checks > 0 ? (up / checks) * 100 : 0;
|
||||
}
|
||||
|
||||
function Heartbeat({ rollups }: { rollups: Rollup[] }) {
|
||||
const recent = rollups.slice(-48);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5">
|
||||
{recent.map((r) => {
|
||||
const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
|
||||
const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
|
||||
return (
|
||||
<div
|
||||
key={r.period_start}
|
||||
className={`h-8 w-1.5 rounded-sm ${color}`}
|
||||
title={`${new Date(r.period_start).toLocaleString()} — ${pct.toFixed(0)}% up`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
|
||||
</div>
|
||||
);
|
||||
const recent = rollups.slice(-48);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5">
|
||||
{recent.map((r) => {
|
||||
const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
|
||||
const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
|
||||
return <div key={r.period_start} className={`h-8 w-1.5 rounded-sm ${color}`} title={`${new Date(r.period_start).toLocaleString()} ${pct.toFixed(0)}% up`} />;
|
||||
})}
|
||||
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonitorDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: rollups } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "uptime"],
|
||||
queryFn: () => api.getMonitorUptime(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const { data: rollups } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "uptime"],
|
||||
queryFn: () => api.getMonitorUptime(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: incidents } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "incidents"],
|
||||
queryFn: () => api.getMonitorIncidents(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const { data: incidents } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "incidents"],
|
||||
queryFn: () => api.getMonitorIncidents(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteMonitor(monitorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push("/monitors");
|
||||
},
|
||||
});
|
||||
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteMonitor(monitorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push("/monitors");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: toggleEnabled } = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
|
||||
});
|
||||
const { mutate: toggleEnabled } = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const all = rollups ?? [];
|
||||
const last24 = all.slice(-24);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
|
||||
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
|
||||
<Badge variant="neutral">{monitor.type}</Badge>
|
||||
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
|
||||
</div>
|
||||
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/monitors/${monitorId}/edit`}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (24h)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (30d)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Latency</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Cert expiry</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "n/a"}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
const all = rollups ?? [];
|
||||
const last24 = all.slice(-24);
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Heartbeat (last 48h)</CardTitle>
|
||||
</CardHeader>
|
||||
<Heartbeat rollups={all} />
|
||||
</Card>
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
|
||||
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
|
||||
<Badge variant="neutral">{monitor.type}</Badge>
|
||||
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
|
||||
</div>
|
||||
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
|
||||
</div>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Started</Th>
|
||||
<Th>Resolved</Th>
|
||||
<Th>Cause</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{incidents.map((inc) => (
|
||||
<Tr key={inc.incident_id}>
|
||||
<Td>
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
{inc.resolved_at ? (
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<Badge variant="danger">ongoing</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-xs text-text-primary">{inc.cause || "n/a"}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Runner</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Interval</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Retries before down</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Target</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
|
||||
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/monitors/${monitorId}/edit`}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (24h)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (30d)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Latency</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Cert expiry</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">
|
||||
{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "—"}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Heartbeat (last 48h)</CardTitle>
|
||||
</CardHeader>
|
||||
<Heartbeat rollups={all} />
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
|
||||
</div>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Started</Th>
|
||||
<Th>Resolved</Th>
|
||||
<Th>Cause</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{incidents.map((inc) => (
|
||||
<Tr key={inc.incident_id}>
|
||||
<Td>
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
{inc.resolved_at ? (
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<Badge variant="danger">ongoing</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-xs text-text-primary">{inc.cause || "—"}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Runner</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Interval</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Retries before down</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Target</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
|
||||
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function MonitorsPage() {
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"}
|
||||
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "n/a"}
|
||||
</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -45,14 +45,29 @@ function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">CPU</span>
|
||||
<span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">Memory</span>
|
||||
<span className="text-text-primary">
|
||||
{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">Swap</span>
|
||||
<span className="text-text-primary">
|
||||
{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +79,9 @@ function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
|
||||
<span className="text-text-secondary">
|
||||
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
@@ -159,7 +176,7 @@ function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => voi
|
||||
</div>
|
||||
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
|
||||
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve — shorter keys, good compatibility.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
@@ -220,70 +237,64 @@ function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatesModal({ updates, onClose, onApply, isApplying, applySuccess }: { updates: PackageUpdate[]; onClose: () => void; onApply: () => void; isApplying: boolean; applySuccess: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">
|
||||
{updates.length} package{updates.length !== 1 ? "s" : ""} available
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
function UpdatesModal({
|
||||
updates,
|
||||
onClose,
|
||||
onApply,
|
||||
isApplying,
|
||||
applySuccess,
|
||||
}: {
|
||||
updates: PackageUpdate[];
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
isApplying: boolean;
|
||||
applySuccess: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">{updates.length} package{updates.length !== 1 ? "s" : ""} available</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td>
|
||||
<span className="font-medium font-mono text-sm">{u.name}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-success">{u.new_version}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td><span className="font-medium font-mono text-sm">{u.name}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-text-secondary">{u.current_version || "—"}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-success">{u.new_version}</span></Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -294,8 +305,8 @@ export default function ServerDetailPage() {
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
|
||||
const {
|
||||
data: server,
|
||||
@@ -330,18 +341,17 @@ export default function ServerDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
|
||||
mutationFn: () => api.applyUpdates(serverId),
|
||||
onSuccess: () => {
|
||||
setApplySuccess(true);
|
||||
setTimeout(() => {
|
||||
setApplySuccess(false);
|
||||
setShowUpdatesModal(false);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
|
||||
mutationFn: () => api.applyUpdates(serverId),
|
||||
onSuccess: () => {
|
||||
setApplySuccess(true);
|
||||
setTimeout(() => {
|
||||
setApplySuccess(false);
|
||||
setShowUpdatesModal(false);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
@@ -369,13 +379,7 @@ export default function ServerDetailPage() {
|
||||
<div className="p-8">
|
||||
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
|
||||
{showUpdatesModal && server.available_updates && (
|
||||
<UpdatesModal
|
||||
updates={server.available_updates}
|
||||
onClose={() => setShowUpdatesModal(false)}
|
||||
onApply={() => applyUpdates()}
|
||||
isApplying={isApplying}
|
||||
applySuccess={applySuccess}
|
||||
/>
|
||||
<UpdatesModal updates={server.available_updates} onClose={() => setShowUpdatesModal(false)} onApply={() => applyUpdates()} isApplying={isApplying} applySuccess={applySuccess} />
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
@@ -396,20 +400,24 @@ export default function ServerDetailPage() {
|
||||
<Link key={p} href={`/servers/${serverId}/console?protocol=${p}`}>
|
||||
<Button variant="secondary">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
|
||||
/>
|
||||
</svg>
|
||||
Connect {p.toUpperCase()}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{server.available_updates && server.available_updates.length > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowUpdatesModal(true)}
|
||||
className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10"
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setShowUpdatesModal(true)} className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
@@ -454,7 +462,7 @@ export default function ServerDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "—"}</span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "n/a"}</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && <Badge variant="warning">update available</Badge>}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && <Badge variant="success">up to date</Badge>}
|
||||
@@ -470,7 +478,8 @@ export default function ServerDetailPage() {
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span> <span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
|
||||
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand(server.os_info));
|
||||
|
||||
@@ -117,7 +117,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
|
||||
|
||||
{token && (
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-warning">Copy this token now — it will not be shown again.</p>
|
||||
<p className="mb-2 text-xs font-medium text-warning">Copy this token now it will not be shown again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{token}</code>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy}>
|
||||
@@ -139,7 +139,6 @@ export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
@@ -168,8 +167,7 @@ export default function SettingsPage() {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!settings) return;
|
||||
|
||||
|
||||
|
||||
save({
|
||||
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
|
||||
email: settings.email,
|
||||
@@ -182,9 +180,7 @@ export default function SettingsPage() {
|
||||
<div className="p-8">
|
||||
<Card className="max-w-lg">
|
||||
<h1 className="text-base font-semibold text-text-primary">You don't have access</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Settings are available to owners and admins only. Ask an administrator if you need access.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-text-secondary">Settings are available to owners and admins only. Ask an administrator if you need access.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +202,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Alerting — replaces the legacy webhook/email settings */}
|
||||
<SectionCard title="Alerting" description="Alerts are now delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/settings/notifications">
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function StepsPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{count === 0 ? "—" : `${count} workflow${count === 1 ? "" : "s"}`}
|
||||
{count === 0 ? "0" : `${count} workflow${count === 1 ? "" : "s"}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-3 text-text-secondary">
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
|
||||
|
||||
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
|
||||
|
||||
function cellKind(status: string): CellKind {
|
||||
@@ -23,7 +21,7 @@ function cellKind(status: string): CellKind {
|
||||
case "cancelled":
|
||||
return "warn";
|
||||
default:
|
||||
return "wait";
|
||||
return "wait";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +43,6 @@ const cellClass: Record<CellKind, string> = {
|
||||
warn: "bg-warning/15 text-warning",
|
||||
};
|
||||
|
||||
|
||||
|
||||
type PillKind = "running" | "success" | "failed" | "neutral";
|
||||
|
||||
function pillKind(status: string): PillKind {
|
||||
@@ -84,8 +80,6 @@ function StatusPill({ status, small }: { status: string; small?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
const s = Math.floor(ms / 1000);
|
||||
@@ -104,8 +98,6 @@ function stepDuration(st: StepRun, running: boolean, now: number): string {
|
||||
return fmtDuration(end - start);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
|
||||
const [text, setText] = useState("");
|
||||
const preRef = useRef<HTMLDivElement>(null);
|
||||
@@ -155,9 +147,6 @@ function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
|
||||
|
||||
function LogLines({ text }: { text: string }) {
|
||||
@@ -189,8 +178,6 @@ function LogLines({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function StepList({ server, now }: { server: ServerRun; now: number }) {
|
||||
const running = server.status === "running";
|
||||
return (
|
||||
@@ -228,8 +215,6 @@ function StepList({ server, now }: { server: ServerRun; now: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Column {
|
||||
order: number;
|
||||
name: string;
|
||||
@@ -295,8 +280,6 @@ function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRu
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-3 mt-8 flex items-center gap-2.5 font-mono text-[11px] uppercase tracking-widest text-text-secondary">
|
||||
@@ -320,7 +303,6 @@ export default function RunDetail() {
|
||||
|
||||
const running = run?.status === "running";
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
@@ -329,7 +311,6 @@ export default function RunDetail() {
|
||||
|
||||
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
|
||||
|
||||
|
||||
const selectedServer = useMemo(() => {
|
||||
if (!run || run.server_runs.length === 0) return null;
|
||||
if (selected) {
|
||||
@@ -371,7 +352,7 @@ export default function RunDetail() {
|
||||
</span>
|
||||
<span className="h-[3px] w-[3px] rounded-full bg-border" />
|
||||
<span>
|
||||
triggered by <b className="font-medium text-text-primary">{run.triggered_by || "—"}</b>
|
||||
triggered by <b className="font-medium text-text-primary">{run.triggered_by || "n/a"}</b>
|
||||
</span>
|
||||
<span className="h-[3px] w-[3px] rounded-full bg-border" />
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user