fix: Fixed card header margin
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 1m0s

This commit is contained in:
2026-09-07 08:36:18 +00:00
parent 28f746c7e2
commit 9d17f539b5
4 changed files with 302 additions and 359 deletions
+178 -225
View File
@@ -136,7 +136,7 @@ function PrivateKeyCard({ keyId }: { keyId: string }) {
return (
<Card>
<CardHeader>
<CardHeader className="mb-4">
<CardTitle>Private Key</CardTitle>
{revealed && (
<div className="flex gap-2">
@@ -255,232 +255,185 @@ export default function KeyDetailPage() {
const assignedServerIds = activeAssignments.map((a) => a.server_id);
return (
<div className="p-4 sm:p-6 lg:p-8">
{showAssign && (
<AssignModal
keyId={keyId}
assignedServerIds={assignedServerIds}
onClose={() => setShowAssign(false)}
/>
)}
<div className="p-4 sm:p-6 lg:p-8">
{showAssign && <AssignModal keyId={keyId} assignedServerIds={assignedServerIds} onClose={() => setShowAssign(false)} />}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{key.label}</h1>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>
{key.source}
</Badge>
</div>
{/* A fingerprint is one unbreakable token; without break-all it
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{key.label}</h1>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>{key.source}</Badge>
</div>
{/* A fingerprint is one unbreakable token; without break-all it
overflows the column rather than wrapping. */}
<p className="mt-1 break-all font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setShowAssign(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Assign to Server
</Button>
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
</div>
<p className="mt-1 break-all font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setShowAssign(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Assign to Server
</Button>
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
</div>
</div>
{/*
* Typed, like the server and monitor deletes. Deleting a key revokes it
* from every server at once, and for a generated key the stored private
* half goes with it — there is no copy anywhere else, so this is the one
* delete on the fleet side that cannot be undone by re-uploading what
* the operator already has.
*/}
<ConfirmDialog
open={confirmDelete}
title="Delete SSH key"
confirmLabel="Delete key"
requireTyped={key.label}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteKey()}
body={
<>
<p>
<span className="font-mono text-text-primary">{key.label}</span> is deleted
{activeAssignments.length > 0 && (
<>
{" "}
and revoked from{" "}
<span className="text-text-primary">
{activeAssignments.length} server{activeAssignments.length !== 1 ? "s" : ""}
</span>
</>
)}
. Agents rewrite authorized_keys within 30 seconds.
</p>
{key.has_private_key && <p>The stored private key is destroyed with it. If this is the only copy, access it grants is gone for good.</p>}
</>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card>
<CardHeader className="mb-4">
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Key ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{key.key_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Source</dt>
<dd className="mt-0.5 text-text-primary capitalize">{key.source}</dd>
</div>
{key.generated_by_server_id && (
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Generated By</dt>
<dd className="mt-0.5">
<Link href={`/servers/${key.generated_by_server_id}`} className="font-mono text-xs text-accent hover:underline">
{key.generated_by_server_id}
</Link>
</dd>
</div>
)}
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Active Assignments</dt>
<dd className="mt-0.5 text-text-primary">{activeAssignments.length}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Created</dt>
<dd className="mt-0.5 text-text-primary">{new Date(key.created_at).toLocaleString()}</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Public Key</CardTitle>
<button onClick={handleCopyKey} className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary">
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</CardHeader>
<div className="rounded-lg border border-border bg-well p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">{key.public_key}</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<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">
Server Assignments
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{activeAssignments.length} active</span>
</h2>
</div>
{!key.assignments || key.assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">Not assigned to any servers.</p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => setShowAssign(true)}>
Assign to a server
</Button>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>IP Address</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th>Revoked</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td label="Server">
<Link href={`/servers/${assignment.server_id}`} className="font-medium text-text-primary hover:text-accent">
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td label="IP Address">
<span className="font-mono text-xs text-text-secondary">{assignment.server?.ip_address ?? "n/a"}</span>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">{new Date(assignment.assigned_at).toLocaleDateString()}</span>
</Td>
<Td label="Revoked">
<span className="text-text-secondary text-xs">{assignment.revoked_at ? new Date(assignment.revoked_at).toLocaleDateString() : "n/a"}</span>
</Td>
<Td>
{!assignment.revoked_at && (
<Button variant="danger" size="sm" onClick={() => revokeKey(assignment.server_id)}>
Revoke
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
{/*
* Typed, like the server and monitor deletes. Deleting a key revokes it
* from every server at once, and for a generated key the stored private
* half goes with it — there is no copy anywhere else, so this is the one
* delete on the fleet side that cannot be undone by re-uploading what
* the operator already has.
*/}
<ConfirmDialog
open={confirmDelete}
title="Delete SSH key"
confirmLabel="Delete key"
requireTyped={key.label}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteKey()}
body={
<>
<p>
<span className="font-mono text-text-primary">{key.label}</span> is deleted
{activeAssignments.length > 0 && (
<>
{" "}
and revoked from{" "}
<span className="text-text-primary">
{activeAssignments.length} server{activeAssignments.length !== 1 ? "s" : ""}
</span>
</>
)}
. Agents rewrite authorized_keys within 30 seconds.
</p>
{key.has_private_key && (
<p>
The stored private key is destroyed with it. If this is the only copy, access it grants is gone for
good.
</p>
)}
</>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Key ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{key.key_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Source</dt>
<dd className="mt-0.5 text-text-primary capitalize">{key.source}</dd>
</div>
{key.generated_by_server_id && (
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Generated By</dt>
<dd className="mt-0.5">
<Link
href={`/servers/${key.generated_by_server_id}`}
className="font-mono text-xs text-accent hover:underline"
>
{key.generated_by_server_id}
</Link>
</dd>
</div>
)}
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Active Assignments</dt>
<dd className="mt-0.5 text-text-primary">{activeAssignments.length}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Created</dt>
<dd className="mt-0.5 text-text-primary">
{new Date(key.created_at).toLocaleString()}
</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>Public Key</CardTitle>
<button
onClick={handleCopyKey}
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</CardHeader>
<div className="rounded-lg border border-border bg-well p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{key.public_key}
</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<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">
Server Assignments
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
{activeAssignments.length} active
</span>
</h2>
</div>
{!key.assignments || key.assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">Not assigned to any servers.</p>
<Button
variant="secondary"
size="sm"
className="mt-3"
onClick={() => setShowAssign(true)}
>
Assign to a server
</Button>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>IP Address</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th>Revoked</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td label="Server">
<Link
href={`/servers/${assignment.server_id}`}
className="font-medium text-text-primary hover:text-accent"
>
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td label="IP Address">
<span className="font-mono text-xs text-text-secondary">
{assignment.server?.ip_address ?? "n/a"}
</span>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">
{new Date(assignment.assigned_at).toLocaleDateString()}
</span>
</Td>
<Td label="Revoked">
<span className="text-text-secondary text-xs">
{assignment.revoked_at
? new Date(assignment.revoked_at).toLocaleDateString()
: "n/a"}
</span>
</Td>
<Td>
{!assignment.revoked_at && (
<Button
variant="danger"
size="sm"
onClick={() => revokeKey(assignment.server_id)}
>
Revoke
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -249,7 +249,7 @@ function AddKeyCard({ group }: { group: string }) {
return (
<Card>
<CardHeader>
<CardHeader className="mb-4">
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
+122 -132
View File
@@ -25,138 +25,128 @@ export default function NewServerPage() {
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">
Generate an install command to register a new server with the Vantage agent.
</p>
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">Generate an install command to register a new server with the Vantage agent.</p>
</div>
<div className="max-w-2xl space-y-6">
{!result ? (
<Card>
<CardHeader className="mb-4">
<CardTitle>Generate Install Command</CardTitle>
</CardHeader>
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command contains a short-lived token (valid for 1 hour) that registers your server and installs the Vantage agent automatically.
</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">Failed to generate install command. Make sure the backend is running.</div>}
<Button variant="primary" loading={isPending} onClick={() => createServer()}>
Generate Install Command
</Button>
</Card>
) : (
<>
<Card>
<CardHeader className="mb-4">
<CardTitle>Install Command</CardTitle>
<span className="rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success border border-success/30">Valid for 1 hour</span>
</CardHeader>
<div className="mb-4 flex gap-2">
{(["linux", "windows"] as const).map((o) => (
<button
key={o}
onClick={() => {
setOs(o);
setCopied(false);
}}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
os === o ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
</button>
))}
</div>
<p className="mb-4 text-sm text-text-secondary">
{os === "windows" ? (
<>
Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):
</>
) : (
<>
Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:
</>
)}
</p>
<div className="relative rounded-lg border border-border bg-well p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
</pre>
<button
onClick={handleCopy}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Server Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-text-secondary">Server ID</dt>
<dd className="font-mono text-text-primary">{result.server_id}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<dt className="text-text-secondary">Status</dt>
<dd className="text-warning">Pending registration</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>What happens next?</CardTitle>
</CardHeader>
<ol className="space-y-3 text-sm text-text-secondary">
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/vantage/config.yaml with the server ID and token",
"Installs and starts the vantage-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/20 text-xs font-semibold text-accent">{i + 1}</span>
{step}
</li>
))}
</ol>
</Card>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setResult(null);
setCopied(false);
}}
>
Generate Another
</Button>
</div>
</>
)}
</div>
</div>
<div className="max-w-2xl space-y-6">
{!result ? (
<Card>
<CardHeader>
<CardTitle>Generate Install Command</CardTitle>
</CardHeader>
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command
contains a short-lived token (valid for 1 hour) that registers your server
and installs the Vantage agent automatically.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
Failed to generate install command. Make sure the backend is running.
</div>
)}
<Button
variant="primary"
loading={isPending}
onClick={() => createServer()}
>
Generate Install Command
</Button>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Install Command</CardTitle>
<span className="rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success border border-success/30">
Valid for 1 hour
</span>
</CardHeader>
<div className="mb-4 flex gap-2">
{(["linux", "windows"] as const).map((o) => (
<button
key={o}
onClick={() => { setOs(o); setCopied(false); }}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
os === o
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
</button>
))}
</div>
<p className="mb-4 text-sm text-text-secondary">
{os === "windows" ? (
<>Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):</>
) : (
<>Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:</>
)}
</p>
<div className="relative rounded-lg border border-border bg-well p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span>{" "}
<span className="text-text-primary">{command}</span>
</pre>
<button
onClick={handleCopy}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? (
<span className="text-success">Copied!</span>
) : (
"Copy"
)}
</button>
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Server Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-text-secondary">Server ID</dt>
<dd className="font-mono text-text-primary">{result.server_id}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<dt className="text-text-secondary">Status</dt>
<dd className="text-warning">Pending registration</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>What happens next?</CardTitle>
</CardHeader>
<ol className="space-y-3 text-sm text-text-secondary">
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/vantage/config.yaml with the server ID and token",
"Installs and starts the vantage-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/20 text-xs font-semibold text-accent">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</Card>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => { setResult(null); setCopied(false); }}>
Generate Another
</Button>
</div>
</>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function Card({ className, padding = true, children, ...props }: CardProp
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx("mb-4 flex flex-wrap items-center justify-between gap-2", className)} {...props}>
<div className={clsx("flex flex-wrap items-center justify-between gap-2", className)} {...props}>
{children}
</div>
);