feat: Added package management
This commit is contained in:
+457
-415
@@ -1,76 +1,194 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ServerStatus, GenerateKeyOptions } from "@/lib/api";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function statusVariant(status: ServerStatus) {
|
||||
switch (status) {
|
||||
case "active": return "success";
|
||||
case "pending": return "warning";
|
||||
case "offline": return "danger";
|
||||
}
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "offline":
|
||||
return "danger";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: Record<string, number> = {
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
};
|
||||
|
||||
function GenerateKeyModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSubmit: (opts: GenerateKeyOptions) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
|
||||
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-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</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>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={(e) => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
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-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<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"
|
||||
@@ -81,383 +199,307 @@ function GenerateKeyModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={e => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
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-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
|
||||
const { data: server, isLoading, error } = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
setShowGenerateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
|
||||
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 (error || !server) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
|
||||
Server not found or failed to load.
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showGenerateModal && (
|
||||
<GenerateKeyModal
|
||||
onClose={() => setShowGenerateModal(false)}
|
||||
onSubmit={opts => generateKey(opts)}
|
||||
isPending={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
>
|
||||
<svg className="h-4 w-4" 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>
|
||||
Generate SSH Key
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={isDeleting}
|
||||
onClick={() => deleteServer()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{latestVersion ? `v${latestVersion.version}` : "—"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{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">$</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
|
||||
{server.keys?.filter(k => !k.revoked_at).length ?? 0} active
|
||||
</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">Manage Keys →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys.filter(a => a.key).map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{assignment.key.fingerprint}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>
|
||||
{assignment.key.source}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
|
||||
{assignment.revoked_at ? "revoked" : "active"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">
|
||||
{formatDate(assignment.assigned_at)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<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();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
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 {
|
||||
data: server,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
setShowGenerateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
|
||||
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 (error || !server) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowGenerateModal(true)}>
|
||||
<svg className="h-4 w-4" 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>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteServer()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "—"}</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>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{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">$</span> <span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed SSH Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{server.keys?.filter((k) => !k.revoked_at).length ?? 0} active</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage Keys →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys
|
||||
.filter((a) => a.key)
|
||||
.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user