feat: Removed comments
Server Deploy / deploy (push) Failing after 1m13s

This commit is contained in:
2026-07-24 09:56:54 +01:00
parent 1a6cf03c03
commit e798365be2
27 changed files with 1557 additions and 1714 deletions
+2 -2
View File
@@ -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
View File
@@ -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>
);
}
+200 -208
View File
@@ -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>
);
);
}
+1 -1
View File
@@ -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>
+106 -97
View File
@@ -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));
+3 -8
View File
@@ -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&apos;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">
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -9,12 +9,12 @@ const MIN_PASSWORD_LENGTH = 8;
/**
* Org hosts are `<slug>.vantage.<rest>` and the apex is `vantage.<rest>` (see
* auth.hostSlug on the server). Build the new org's URL by prepending or
* replacing the leftmost label. Hosts that don't match that shape (localhost,
* auth.hostSlug on the server). Build the new org's URL by prepending or
* replacing the leftmost label. Hosts that don't match that shape (localhost,
* bare IPs) have no per-org subdomain, so stay put.
*
* Setup runs on the apex, and the session cookie it sets is scoped to that
* exact host by design org hosts must not share cookies. So the new owner is
* exact host by design org hosts must not share cookies. So the new owner is
* sent to the org host's *login* page to sign in there, which is what puts a
* session cookie on the host their org actually lives on.
*/
+66 -79
View File
@@ -6,16 +6,16 @@ import { auth, type Org, type Role, type SessionUser } from "@/lib/api";
export type { Org, Role, SessionUser };
interface AuthContextType {
user: SessionUser | null;
org: Org | null;
/** True for owner and admin the roles the /api/settings and /api/org routes require. */
isAdmin: boolean;
user: SessionUser | null;
org: Org | null;
/** True for owner and admin the roles the /api/settings and /api/org routes require. */
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, org: null, isAdmin: false });
export function useAuth() {
return useContext(AuthContext);
return useContext(AuthContext);
}
/**
@@ -23,86 +23,73 @@ export function useAuth() {
* /setup live outside the group, so no pathname guard is needed here.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(null);
const [org, setOrg] = useState<Org | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [user, setUser] = useState<SessionUser | null>(null);
const [org, setOrg] = useState<Org | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await auth.bootstrapStatus();
if (status.needs_setup) {
window.location.href = "/setup";
return;
}
(async () => {
try {
const status = await auth.bootstrapStatus();
if (status.needs_setup) {
window.location.href = "/setup";
return;
}
const me = await auth.me();
if (cancelled) return;
setUser(me.user);
setOrg(me.org);
setLoading(false);
} catch (err) {
if (cancelled) return;
const status = (err as { status?: number }).status;
if (status === 401) {
window.location.href = "/login";
return;
}
setError((err as Error).message || "Unable to load your session.");
setLoading(false);
}
})();
const me = await auth.me();
if (cancelled) return;
setUser(me.user);
setOrg(me.org);
setLoading(false);
} catch (err) {
if (cancelled) return;
const status = (err as { status?: number }).status;
if (status === 401) {
window.location.href = "/login";
return;
}
return () => {
cancelled = true;
};
}, []);
setError((err as Error).message || "Unable to load your session.");
setLoading(false);
}
})();
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
return () => {
cancelled = true;
};
}, []);
if (error || !user) {
return (
<div className="flex h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 text-center">
<h1 className="text-base font-semibold text-text-primary">Can&apos;t load your session</h1>
<p className="mt-2 text-sm text-text-secondary">
{error ?? "Unable to load your session."}
</p>
<div className="mt-5 flex justify-center gap-2">
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white"
>
Retry
</button>
<a
href="/login"
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-secondary"
>
Sign in
</a>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
const isAdmin = user.role === "owner" || user.role === "admin";
if (error || !user) {
return (
<div className="flex h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 text-center">
<h1 className="text-base font-semibold text-text-primary">Can&apos;t load your session</h1>
<p className="mt-2 text-sm text-text-secondary">{error ?? "Unable to load your session."}</p>
<div className="mt-5 flex justify-center gap-2">
<button onClick={() => window.location.reload()} className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white">
Retry
</button>
<a href="/login" className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-secondary">
Sign in
</a>
</div>
</div>
</div>
);
}
return (
<AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>
);
const isAdmin = user.role === "owner" || user.role === "admin";
return <AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>;
}
+164 -138
View File
@@ -7,182 +7,208 @@ import { useAuth } from "@/components/AuthProvider";
import { auth } from "@/lib/api";
interface NavItem {
href: string;
label: string;
icon: React.ReactNode;
/** Restricted to owner/admin the roles the backing API requires. */
adminOnly?: boolean;
href: string;
label: string;
icon: React.ReactNode;
/** Restricted to owner/admin the roles the backing API requires. */
adminOnly?: boolean;
}
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" 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>
);
return (
<svg className="h-5 w-5" 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>
);
}
function SecretIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
}
function WorkflowIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.012 1.244h3.22a2.25 2.25 0 002.012-1.244l.256-.512a2.25 2.25 0 012.012-1.244h3.86M12 3v8.25m0 0l-3-3m3 3l3-3" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.012 1.244h3.22a2.25 2.25 0 002.012-1.244l.256-.512a2.25 2.25 0 012.012-1.244h3.86M12 3v8.25m0 0l-3-3m3 3l3-3"
/>
</svg>
);
}
function AuditIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
);
}
function SettingsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
function MonitorIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12h4l2 6 4-14 2 8h6" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12h4l2 6 4-14 2 8h6" />
</svg>
);
}
function StepsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.75A.75.75 0 016.75 6h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 6.75zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 17.25zM3 6.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM3 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 5.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 6.75A.75.75 0 016.75 6h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 6.75zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 17.25zM3 6.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM3 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 5.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/>
</svg>
);
}
function OrgIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/org", label: "Organization", icon: <OrgIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/org", label: "Organization", icon: <OrgIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
export function Sidebar() {
const pathname = usePathname();
const { user, org, isAdmin } = useAuth();
const pathname = usePathname();
const { user, org, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
async function handleLogout() {
try {
await auth.logout();
} catch {
async function handleLogout() {
try {
await auth.logout();
} catch {}
window.location.href = "/login";
}
window.location.href = "/login";
}
return (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
<div className="flex h-16 items-center gap-3 border-b border-border px-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<div className="min-w-0">
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
{org && <span className="block truncate text-xs text-text-secondary">{org.name}</span>}
</div>
</div>
return (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
<div className="flex h-16 items-center gap-3 border-b border-border px-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<div className="min-w-0">
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
{org && <span className="block truncate text-xs text-text-secondary">{org.name}</span>}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={clsx(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-accent/15 text-accent"
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={clsx(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive ? "bg-accent/15 text-accent" : "text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-t border-border px-4 py-3">
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">Vantage v1.0</p>
{user && (
<button
type="button"
onClick={handleLogout}
className="text-xs text-text-secondary transition-colors hover:text-danger"
>
Logout
</button>
)}
</div>
</div>
</aside>
);
<div className="border-t border-border px-4 py-3">
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">Vantage v1.0</p>
{user && (
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
Logout
</button>
)}
</div>
</div>
</aside>
);
}
+628 -660
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -1,5 +1,5 @@
// Thin wrapper over the vendored guacamole-common-js client.
// The library attaches a global `Guacamole` object when loaded.
declare const Guacamole: any;
export function openConsole(
@@ -7,14 +7,14 @@ export function openConsole(
wsUrl: string,
connectData = ""
): { disconnect: () => void; setScale: (scale: number) => void; resize: (width: number, height: number) => void } {
// Guacamole's WebSocketTunnel builds the socket URL as `wsUrl + "?" + data`,
// so wsUrl must NOT already contain a query string — pass params via connectData.
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
container.innerHTML = "";
container.appendChild(client.getDisplay().getElement());
// Make the console focusable so keyboard capture is scoped to it (see below).
container.tabIndex = 0;
client.connect(connectData);
@@ -22,10 +22,10 @@ export function openConsole(
const display = client.getDisplay();
let scale = 1;
// Wire keyboard + mouse. The display element is rendered at `scale` of the
// remote's native resolution, but Guacamole.Mouse reports coordinates in
// element (on-screen) pixels. Divide by scale to map back to remote
// coordinates, otherwise the cursor is offset.
const mouse = new Guacamole.Mouse(display.getElement());
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) => {
const s = new Guacamole.Mouse.State(
@@ -39,15 +39,15 @@ export function openConsole(
);
client.sendMouseState(s);
};
// Scope keyboard capture to the container rather than `document`, so it only
// grabs keys while the console is focused and stops entirely once the element
// is removed (navigating away / disconnect). Attaching to `document` leaks the
// capture and swallows keystrokes in unrelated inputs.
const keyboard = new Guacamole.Keyboard(container);
keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k);
keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k);
// Guacamole.Mouse consumes the native mousedown, so clicking the console never
// moves DOM focus back to it. Refocus explicitly so keyboard capture resumes.
const refocus = () => container.focus();
container.addEventListener("mousedown", refocus);
container.focus();
+4 -4
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/
/
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.