feat(web): login, first-run setup, org settings; org-aware AuthProvider

- Route group (app) holds AuthProvider + Sidebar, so /login and /setup
  render without app chrome and never mount the provider.
- AuthProvider drops the removed auth_enabled flag and exposes
  {user, org, isAdmin}.
- New login page (password + SSO), first-run setup page, and org settings
  page with a members table and the OIDC provider form.
- Settings page and the Organization nav entry are gated on role, since
  /api/settings now 403s for members.
- GET /api/org/oidc gains client_secret_set so the UI can show whether a
  secret is stored; the secret itself is still never serialized, and an
  empty submitted value still means "keep the stored one".
- Fix logout: the sidebar linked to /auth/logout with a GET, but the route
  is POST-only, so logout was 404ing.
This commit is contained in:
2026-07-22 10:17:12 +01:00
parent 156c5354de
commit e70b2f0e67
30 changed files with 979 additions and 55 deletions
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</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 audit log.</div>
) : events && events.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</Card>
</div>
);
}
+439
View File
@@ -0,0 +1,439 @@
"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, Server } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function AssignModal({
keyId,
assignedServerIds,
onClose,
}: {
keyId: string;
assignedServerIds: string[];
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedServer, setSelectedServer] = useState("");
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: api.listServers,
});
const { mutate: assign, isPending, error } = useMutation({
mutationFn: () => api.assignKey(keyId, selectedServer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
onClose();
},
});
const availableServers = servers?.filter(
(s: Server) => !assignedServerIds.includes(s.server_id)
);
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-md rounded-xl border border-border bg-surface p-6">
<h2 className="mb-4 text-lg font-semibold text-text-primary">Assign Key to Server</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>
)}
<div className="mb-4">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Select Server
</label>
{!availableServers || availableServers.length === 0 ? (
<p className="text-sm text-text-secondary">
All servers already have this key assigned.
</p>
) : (
<select
value={selectedServer}
onChange={(e) => setSelectedServer(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 focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="">Choose a server...</option>
{availableServers.map((s: Server) => (
<option key={s.server_id} value={s.server_id}>
{s.hostname} ({s.ip_address})
</option>
))}
</select>
)}
</div>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!selectedServer}
onClick={() => assign()}
>
Assign Key
</Button>
</div>
</div>
</div>
);
}
function PrivateKeyCard({ keyId }: { keyId: string }) {
const [revealed, setRevealed] = useState(false);
const [privateKey, setPrivateKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function reveal() {
setLoading(true);
setError(null);
try {
const res = await api.getPrivateKey(keyId);
setPrivateKey(res.private_key);
setRevealed(true);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
function download() {
if (!privateKey) return;
const blob = new Blob([privateKey], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${keyId}.pem`;
a.click();
URL.revokeObjectURL(url);
}
async function copy() {
if (!privateKey) return;
await navigator.clipboard.writeText(privateKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Private Key</CardTitle>
{revealed && (
<div className="flex gap-2">
<button
onClick={copy}
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"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
<button
onClick={download}
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"
>
Download .pem
</button>
</div>
)}
</CardHeader>
{error && (
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
{error}
</div>
)}
{!revealed ? (
<div className="flex flex-col items-center gap-3 py-4">
<p className="text-center text-xs text-text-tertiary">
Stored encrypted (AES-256-GCM). Click to decrypt and display.
</p>
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Reveal Private Key
</Button>
</div>
) : (
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{privateKey}
</pre>
</div>
)}
</Card>
);
}
export default function KeyDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const keyId = params.id as string;
const [showAssign, setShowAssign] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const { data: key, isLoading, error } = useQuery({
queryKey: ["keys", keyId],
queryFn: () => api.getKey(keyId),
});
const { mutate: revokeKey } = useMutation({
mutationFn: (serverId: string) => api.revokeKey(keyId, serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
},
});
const { mutate: deleteKey, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
router.push("/keys");
},
});
const handleCopyKey = async () => {
if (!key?.public_key) return;
await navigator.clipboard.writeText(key.public_key);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
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 || !key) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
Key not found or failed to load.
</div>
</div>
);
}
const activeAssignments = key.assignments?.filter((a) => !a.revoked_at) ?? [];
const assignedServerIds = activeAssignments.map((a) => a.server_id);
return (
<div className="p-8">
{showAssign && (
<AssignModal
keyId={keyId}
assignedServerIds={assignedServerIds}
onClose={() => setShowAssign(false)}
/>
)}
<div className="mb-6 flex items-start justify-between">
<div>
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex 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>
<p className="mt-1 font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex 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>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Delete permanently?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteKey()}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</div>
<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-[#0a0c14] 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>
<Link
href={`/servers/${assignment.server_id}`}
className="font-medium text-text-primary hover:text-accent"
>
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">
{assignment.server?.ip_address ?? "—"}
</span>
</Td>
<Td>
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(assignment.assigned_at).toLocaleDateString()}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{assignment.revoked_at
? new Date(assignment.revoked_at).toLocaleDateString()
: "—"}
</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>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, Key } from "@/lib/api";
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 { 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>
{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>
<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 { data: keys, isLoading, error } = useQuery({
queryKey: ["keys"],
queryFn: api.listKeys,
});
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>
<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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { AuthProvider } from "@/components/AuthProvider";
import { Sidebar } from "@/components/Sidebar";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</AuthProvider>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function EditMonitorPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
});
const { mutate: update, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
router.push(`/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>
);
}
return (
<div className="p-8">
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
{monitor.name}
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
"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, 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";
}
}
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;
}
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>
);
}
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 { 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: 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: 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);
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>
<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>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function NewMonitorPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { mutate: create, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
router.push(`/monitors/${m.monitor_id}`);
},
});
return (
<div className="p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, MonitorStatus } from "@/lib/api";
import { Badge, Button, Card, 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";
}
}
function targetSummary(m: Monitor): string {
if (m.type === "http") return m.target.url ?? "";
if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
if (m.type === "icmp") return m.target.host ?? "";
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
}
export default function MonitorsPage() {
const { data: monitors, isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: () => api.listMonitors(),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notifications</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New Monitor</Button>
</Link>
</div>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !monitors || monitors.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No monitors yet.</p>
<Link href="/monitors/new">
<Button variant="secondary" size="sm" className="mt-3">
Create your first monitor
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Latency</Th>
<Th>Last check</Th>
</Tr>
</Thead>
<Tbody>
{monitors.map((m) => (
<Tr key={m.monitor_id}>
<Td>
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
{m.name}
</Link>
</Td>
<Td>
<Badge variant="neutral">{m.type}</Badge>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
</Td>
<Td>
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
</Td>
<Td>
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
</Td>
<Td>
<span className="text-xs text-text-secondary">
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"}
</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/servers");
}
+311
View File
@@ -0,0 +1,311 @@
"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, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"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";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-text-secondary">{label}</span>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs leading-relaxed text-text-primary">{yaml}</pre>
</div>
);
}
function YamlModal({ group, onClose }: { group: string; onClose: () => void }) {
const [namespace, setNamespace] = useState(group);
const readUrl = typeof window !== "undefined" ? window.location.origin : "https://vantage.example.com";
const ns = namespace.trim() || group;
const externalSecret = `apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ${group}
namespace: ${ns}
spec:
refreshInterval: 15m
secretStoreRef:
name: ${STORE_NAME}
kind: ClusterSecretStore
target:
name: ${group}
creationPolicy: Owner
dataFrom:
- extract:
key: ${group}`;
const clusterStore = `apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: ${STORE_NAME}
spec:
provider:
webhook:
url: "${readUrl}/api/secrets/{{ .remoteRef.key }}/values"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: vantage-eso-token
namespace: external-secrets`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">
Kubernetes manifests for <span className="font-mono">{group}</span>
</h2>
<p className="mb-5 text-sm text-text-secondary">Apply the ExternalSecret in your app&apos;s namespace to sync this group into a Kubernetes Secret via ESO.</p>
<div className="mb-5">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Namespace</label>
<input type="text" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={group} className={`${inputClass} font-mono`} />
</div>
<div className="space-y-5">
<CopyBlock label="ExternalSecret (apply per namespace)" yaml={externalSecret} />
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary">One-time cluster setup: ClusterSecretStore</summary>
<p className="mb-3 mt-2 text-xs text-text-tertiary">
Apply this once per cluster. It requires a Secret named <span className="font-mono">vantage-eso-token</span> in the <span className="font-mono">external-secrets</span>{" "}
namespace holding the read token from Settings, labelled <span className="font-mono">external-secrets.io/type=webhook</span>.
</p>
<CopyBlock label="ClusterSecretStore" yaml={clusterStore} />
</details>
</div>
<div className="mt-6 flex justify-end">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const {
mutate: add,
isPending,
error,
} = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<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>
{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="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="API_KEY" className={`${inputClass} font-mono`} />
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="myapikey456" className={`${inputClass} font-mono`} />
</div>
<Button variant="primary" loading={isPending} disabled={!key.trim() || !value} onClick={() => add()}>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const [showYaml, setShowYaml] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => setShowYaml(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="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<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 group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</Card>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"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";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
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-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</p>
{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">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="supersecret123"
className={`${inputClass} font-mono`}
/>
</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={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(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>
New Group
</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 secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<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="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>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { openConsole } from "@/lib/guacConsole";
export default function ServerConsolePage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const serverId = params.id as string;
const containerRef = useRef<HTMLDivElement>(null);
const connectionRef = useRef<{
disconnect: () => void;
setScale: (scale: number) => void;
resize: (width: number, height: number) => void;
} | null>(null);
const [protocol, setProtocol] = useState<string>(searchParams.get("protocol") || "");
const [keyId, setKeyId] = useState<string>("");
const [sshUsername, setSshUsername] = useState<string>("root");
const [rdpUsername, setRdpUsername] = useState("");
const [rdpPassword, setRdpPassword] = useState("");
const [vncPassword, setVncPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<{ token: string; wsPath: string } | null>(null);
const [zoom, setZoom] = useState(1);
const dprRef = useRef(1);
// Inject the vendored Guacamole client script once.
useEffect(() => {
const s = document.createElement("script");
s.src = "/lib/guacamole-common.js";
s.async = true;
document.body.appendChild(s);
return () => {
document.body.removeChild(s);
};
}, []);
// Disconnect on unmount.
useEffect(() => {
return () => {
connectionRef.current?.disconnect();
};
}, []);
const { data: server, isLoading: serverLoading } = useQuery({
queryKey: ["servers", serverId],
queryFn: () => api.getServer(serverId),
});
const { data: keys, isLoading: keysLoading } = useQuery({
queryKey: ["keys"],
queryFn: () => api.listKeys(),
});
const usableKeys = useMemo(() => (keys ?? []).filter((k) => k.has_private_key === true), [keys]);
const protocols = server?.console_protocols ?? [];
useEffect(() => {
if (!protocol && protocols.length > 0) {
setProtocol(protocols[0]);
}
}, [protocols, protocol]);
async function handleConnect() {
setError(null);
setConnecting(true);
try {
const body: Parameters<typeof api.connectConsole>[0] = {
server_id: serverId,
protocol,
};
if (protocol === "ssh") {
body.key_id = keyId || undefined;
body.ssh_username = sshUsername || undefined;
} else if (protocol === "rdp") {
body.rdp_username = rdpUsername || undefined;
body.rdp_password = rdpPassword || undefined;
} else if (protocol === "vnc") {
body.rdp_password = vncPassword || undefined;
}
const { token, ws_path } = await api.connectConsole(body);
// Defer the actual openConsole until after the form is unmounted so the
// container measures at full height (see effect below).
setPending({ token, wsPath: ws_path });
setConnected(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect");
} finally {
setConnecting(false);
}
}
// Runs after `connected` flips and the connection form is gone, so the
// container now occupies its full flex height.
useEffect(() => {
if (!connected || !pending || !containerRef.current) return;
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}${pending.wsPath}`;
const rect = containerRef.current.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
dprRef.current = dpr;
// Request the remote at device-pixel resolution with a fixed 96 dpi, then
// scale the display back down by dpr. Folding dpr into `dpi` instead makes
// the remote enlarge everything, which reads as a zoomed-in view.
const connectData =
`token=${encodeURIComponent(pending.token)}` +
`&width=${Math.floor(rect.width * dpr)}` +
`&height=${Math.floor(rect.height * dpr)}` +
`&dpi=96`;
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData);
connectionRef.current.setScale(zoom / dpr);
setPending(null);
}, [connected, pending]);
// Apply zoom live without reconnecting: resize the remote to a resolution
// that, once scaled to fit the container, yields the requested zoom. Higher
// zoom = fewer remote pixels rendered larger. Display always fits the
// container exactly, so no scrollbars appear.
useEffect(() => {
if (!connectionRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const dpr = dprRef.current;
const remoteW = Math.floor((rect.width * dpr) / zoom);
const remoteH = Math.floor((rect.height * dpr) / zoom);
connectionRef.current.resize(remoteW, remoteH);
connectionRef.current.setScale(zoom / dpr);
}, [zoom]);
function handleDisconnect() {
connectionRef.current?.disconnect();
connectionRef.current = null;
setConnected(false);
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
}
if (serverLoading || keysLoading) {
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 (!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="flex h-full flex-col p-8">
<div className="mb-4 flex items-center gap-3">
<Link href={`/servers/${serverId}`} className="text-text-secondary hover:text-text-primary text-sm">
{server.hostname}
</Link>
</div>
<h1 className="mb-4 text-2xl font-bold text-text-primary">Console</h1>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">{error}</div>}
{!connected ? (
<Card className="mb-4 max-w-xl">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Protocol</label>
<select
value={protocol}
onChange={(e) => setProtocol(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"
>
{protocols.length === 0 && <option value="">No protocols available</option>}
{protocols.map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
</div>
{protocol === "ssh" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Username</label>
<input
type="text"
value={sshUsername}
onChange={(e) => setSshUsername(e.target.value)}
placeholder="root"
className="mb-3 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"
/>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Key</label>
<select
value={keyId}
onChange={(e) => setKeyId(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"
>
<option value="">Select a key</option>
{usableKeys.map((k) => (
<option key={k.key_id} value={k.key_id}>
{k.label}
</option>
))}
</select>
{usableKeys.length === 0 && (
<p className="mt-1.5 text-xs text-text-tertiary">No keys with stored private material are available.</p>
)}
</div>
)}
{protocol === "rdp" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Username</label>
<input
type="text"
value={rdpUsername}
onChange={(e) => setRdpUsername(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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={rdpPassword}
onChange={(e) => setRdpPassword(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 focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
</>
)}
{protocol === "vnc" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={vncPassword}
onChange={(e) => setVncPassword(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 focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
)}
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
Connect
</Button>
</div>
</Card>
) : (
<div className="mb-4 flex items-center gap-3">
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
<label className="text-sm text-text-secondary">Scale</label>
<select
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="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"
>
<option value={0.5}>50%</option>
<option value={0.75}>75%</option>
<option value={1}>100%</option>
<option value={1.25}>125%</option>
<option value={1.5}>150%</option>
<option value={2}>200%</option>
</select>
</div>
)}
<div
ref={containerRef}
className="min-h-[500px] flex-1 overflow-hidden rounded-lg border border-border bg-black"
/>
</div>
);
}
+596
View File
@@ -0,0 +1,596 @@
"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, PackageUpdate, Inventory } 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";
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
function UsageBar({ used, total }: { used: number; total: number }) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
</div>
);
}
function InventoryPanel({ inv }: { inv: Inventory }) {
return (
<Card>
<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>
<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>
</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>
<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>
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
</div>
</div>
{inv.partitions && inv.partitions.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
<div className="space-y-3">
{inv.partitions.map((p) => (
<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>
</div>
<UsageBar used={p.used_bytes} total={p.total_bytes} />
</div>
))}
</div>
</div>
)}
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
</Card>
);
}
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
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 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,
});
}
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-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 || "—"}</span></Td>
<Td><span className="font-mono text-xs text-success">{u.new_version}</span></Td>
</Tr>
))}
</Tbody>
</Table>
</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();
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} />}
{showUpdatesModal && server.available_updates && (
<UpdatesModal
updates={server.available_updates}
onClose={() => setShowUpdatesModal(false)}
onApply={() => applyUpdates()}
isApplying={isApplying}
applySuccess={applySuccess}
/>
)}
<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">
{server.console_protocols?.map((p) => (
<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" />
</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"
>
<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" />
</svg>
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
</Button>
)}
<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">{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));
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>
{server.inventory && (
<div className="mb-6">
<InventoryPanel inv={server.inventory} />
</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>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, NewServerResponse } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
export default function NewServerPage() {
const [result, setResult] = useState<NewServerResponse | null>(null);
const [copied, setCopied] = useState(false);
const [os, setOs] = useState<"linux" | "windows">("linux");
const { mutate: createServer, isPending, error } = useMutation({
mutationFn: api.createServer,
onSuccess: (data) => setResult(data),
});
const command = os === "windows" ? result?.install_command_ps : result?.install_command;
const handleCopy = async () => {
if (!command) return;
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="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>
<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-[#0a0c14] 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>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Server } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus {
if (server.status === "offline" || server.status === "pending") return "offline";
if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update";
if (server.available_updates && server.available_updates.length > 0) return "has-package-updates";
return "ok";
}
const DOT_CLASSES: Record<DotStatus, string> = {
offline: "bg-danger",
"needs-update": "bg-orange-500",
"has-package-updates": "bg-yellow-400",
ok: "bg-success",
};
const DOT_LABELS: Record<DotStatus, string> = {
offline: "Offline",
"needs-update": "Agent needs updating",
"has-package-updates": "Package updates available",
ok: "OK",
};
function StatusDot({ status }: { status: DotStatus }) {
return (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
</span>
);
}
function formatLastSeen(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
return `${diffDay}d ago`;
}
export default function ServersPage() {
const { data: servers, isLoading, error } = useQuery({
queryKey: ["servers"],
queryFn: api.listServers,
refetchInterval: 30_000,
});
const { data: latestVersionData } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: api.getLatestAgentVersion,
staleTime: 5 * 60_000,
});
const latestVersion = latestVersionData?.version;
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Servers</h1>
<p className="mt-1 text-sm text-text-secondary">
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
</p>
</div>
<Link href="/servers/new">
<Button variant="primary">
<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>
Add Server
</Button>
</Link>
</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 servers. Is the backend running?
</div>
) : servers && servers.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Hostname</Th>
<Th>IP Address</Th>
<Th>OS</Th>
<Th>Status</Th>
<Th>Last Seen</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{servers.map((server: Server) => (
<Tr key={server.server_id}>
<Td>
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td>
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td>
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td>
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td>
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
</Td>
<Td>
<Link href={`/servers/${server.server_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="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>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Link href="/servers/new">
<Button variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</Link>
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
const inputClass =
"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";
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
// Config fields required per channel type.
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
discord: ["url"],
telegram: ["token", "chat_id"],
smtp: ["host", "port", "username", "password", "from", "to"],
};
function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<ChannelType>(initial?.type ?? "webhook");
const [config, setConfig] = useState<Record<string, string>>(initial?.config ?? {});
const { mutate: submit, isPending, error } = useMutation({
mutationFn: (input: ChannelInput) =>
initial ? api.updateChannel(initial.channel_id, input) : api.createChannel(input).then(() => undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
onDone();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit({ name, type, config, enabled: initial?.enabled ?? true });
}}
className="space-y-4"
>
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={type}
onChange={(e) => {
setType(e.target.value as ChannelType);
setConfig({});
}}
>
{(["webhook", "smtp", "discord", "slack", "telegram"] as const).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{CONFIG_FIELDS[type].map((field) => (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={config[field] ?? ""}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
))}
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{initial ? "Save Changes" : "Add Channel"}
</Button>
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
</form>
);
}
function ChannelRow({ ch }: { ch: NotificationChannel }) {
const queryClient = useQueryClient();
const [testMsg, setTestMsg] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const { mutate: remove } = useMutation({
mutationFn: () => api.deleteChannel(ch.channel_id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testChannel(ch.channel_id),
onSuccess: () => setTestMsg("Sent!"),
onError: (e) => setTestMsg((e as Error).message),
});
const { mutate: toggle } = useMutation({
mutationFn: (enabled: boolean) => api.updateChannel(ch.channel_id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
if (editing) {
return (
<div className="border-b border-border p-4 last:border-0">
<ChannelForm initial={ch} onDone={() => setEditing(false)} />
</div>
);
}
return (
<div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-0">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{ch.name}</span>
<Badge variant="neutral">{ch.type}</Badge>
{!ch.enabled && <Badge variant="warning">disabled</Badge>}
</div>
{testMsg && <p className="mt-1 text-xs text-text-secondary">{testMsg}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
{ch.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="danger" size="sm" onClick={() => remove()}>
Delete
</Button>
</div>
</div>
);
}
export default function NotificationSettingsPage() {
const [showForm, setShowForm] = useState(false);
const { data: channels, isLoading } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<h1 className="mt-2 text-2xl font-bold text-text-primary">Notification Channels</h1>
<p className="mt-1 text-sm text-text-secondary">Alert destinations for monitor state changes.</p>
</div>
{!showForm && (
<Button variant="primary" onClick={() => setShowForm(true)}>
New Channel
</Button>
)}
</div>
{showForm && (
<Card className="mb-6 max-w-xl">
<ChannelForm onDone={() => setShowForm(false)} />
</Card>
)}
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !channels || channels.length === 0 ? (
<div className="py-16 text-center text-sm text-text-secondary">No channels configured.</div>
) : (
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
</div>
);
}
+400
View File
@@ -0,0 +1,400 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, auth as authApi, type OrgUser, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
const ROLES: Role[] = ["owner", "admin", "member"];
const inputClass =
"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";
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
function MembersCard() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [addOpen, setAddOpen] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("member");
const { data: users, isLoading, error } = useQuery({ queryKey: ["org-users"], queryFn: api.listOrgUsers });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["org-users"] });
const { mutate: createUser, isPending: creating, error: createError } = useMutation({
mutationFn: () => api.createOrgUser({ email, password, role }),
onSuccess: () => {
invalidate();
setAddOpen(false);
setEmail("");
setPassword("");
setRole("member");
},
});
const { mutate: changeRole } = useMutation({
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
onSuccess: invalidate,
});
const { mutate: removeUser } = useMutation({
mutationFn: (userId: string) => api.deleteOrgUser(userId),
onSuccess: invalidate,
});
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-text-primary">Members</h2>
<p className="mt-0.5 text-sm text-text-secondary">
People with access to this organization. Owners and admins can manage settings.
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)}>
Add Member
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{(error as Error).message}</p>
) : !users || users.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No members yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((u: OrgUser) => {
const isSelf = u.user_id === user?.user_id;
return (
<Tr key={u.user_id}>
<Td>
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{isSelf ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
value={u.role}
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded-lg border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!isSelf && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this organization?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
<Modal open={addOpen} title="Add Member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
e.preventDefault();
createUser();
}}
className="space-y-4"
>
<Field label="Email">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Password"
hint="Leave blank if this member will sign in through SSO instead."
>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
{createError && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(createError as Error).message}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Add Member
</Button>
</div>
</form>
</Modal>
</Card>
);
}
function OIDCCard() {
const queryClient = useQueryClient();
const { data: cfg, isLoading } = useQuery({ queryKey: ["org-oidc"], queryFn: api.getOrgOIDC });
const [issuer, setIssuer] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [enabled, setEnabled] = useState(false);
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
const redirectUrl = authApi.oidcRedirectUrl();
useEffect(() => {
if (!cfg) return;
setIssuer(cfg.issuer ?? "");
setClientId(cfg.client_id ?? "");
setEnabled(cfg.enabled);
// The secret is never returned; leave the field blank to mean "unchanged".
setClientSecret("");
}, [cfg]);
const { mutate: save, isPending, error } = useMutation({
mutationFn: () => api.saveOrgOIDC({ issuer, client_id: clientId, client_secret: clientSecret, enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["org-oidc"] });
setClientSecret("");
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
async function copyRedirect() {
await navigator.clipboard.writeText(redirectUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (isLoading) {
return (
<Card>
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
</Card>
);
}
const secretSet = cfg?.client_secret_set ?? false;
return (
<Card>
<div className="mb-4">
<h2 className="text-base font-semibold text-text-primary">Single Sign-On (OIDC)</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Let members sign in with your identity provider. Users are provisioned into this organization on
first sign-in.
</p>
</div>
<div className="mb-5 rounded-lg border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">
Register this redirect URL with your provider:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-background px-2 py-1.5 font-mono text-xs text-text-primary">
{redirectUrl}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copyRedirect}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}
className="space-y-4"
>
<Field label="Issuer URL" hint="The provider's OIDC discovery base, e.g. https://accounts.google.com">
<input
type="url"
required
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Client ID">
<input
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Client Secret"
hint={
secretSet
? "A secret is stored. Leave this blank to keep it, or enter a new one to replace it."
: "No secret stored yet."
}
>
<input
type="password"
autoComplete="new-password"
placeholder={secretSet ? "•••••••• (unchanged)" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className={inputClass}
/>
</Field>
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${secretSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{secretSet ? "Client secret is configured" : "No client secret configured"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
Enable SSO sign-in for this organization
</label>
{enabled && !secretSet && !clientSecret && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
SSO cannot complete sign-in without a client secret.
</div>
)}
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save SSO Settings"}
</Button>
{saved && <span className="text-sm text-success">SSO settings saved.</span>}
</div>
</form>
</Card>
);
}
export default function OrgSettingsPage() {
const { org, isAdmin } = useAuth();
if (!isAdmin) {
return (
<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">
Organization settings are available to owners and admins only. Ask an administrator if you need
access.
</p>
</Card>
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Organization</h1>
<p className="mt-1 text-sm text-text-secondary">
{org ? `Manage members and sign-in for ${org.name}.` : "Manage members and sign-in."}
</p>
</div>
<div className="space-y-6">
<MembersCard />
<OIDCCard />
</div>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) {
return (
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">{icon}</div>
<div>
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
</div>
{children}
</Card>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
function BellIcon() {
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="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
);
}
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="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
);
}
function DocumentIcon() {
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="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</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>
);
}
function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl = typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/values";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
async function copy() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<SectionCard title="Secrets Read Token (ESO)" description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token." icon={<KeyIcon />}>
<p className="mb-4 text-sm text-text-secondary">
Point your <span className="font-mono">ClusterSecretStore</span> at <span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{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>
<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}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && <p className="mt-2 text-xs text-text-tertiary">Rotating invalidates the previous token. Update the Kubernetes secret afterwards.</p>}
</SectionCard>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
const { isAdmin } = useAuth();
// /api/settings requires owner|admin and 403s for members, so don't even ask.
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
enabled: isAdmin,
});
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!settings) return;
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!settings) return;
// Preserve legacy alert/email values (managed via Notification Channels now);
// only the offline threshold and log retention are edited here.
save({
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
email: settings.email,
workflow_log_retention_days: logRetentionDays,
});
}
if (!isAdmin) {
return (
<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>
</Card>
</div>
);
}
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>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</p>
</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">
<Button variant="secondary">Manage Notification Channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View Monitors</Button>
</Link>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
</p>
</SectionCard>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard title="Server Health" description="When to consider an agent-backed server offline." icon={<ServerIcon />}>
<Field label="Offline threshold (minutes)" hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum.">
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
className="w-32 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"
/>
</Field>
</SectionCard>
<SectionCard title="Workflow Logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => setLogRetentionDays(Number(e.target.value))}
className="w-32 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"
/>
</Field>
</SectionCard>
</div>
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
</div>
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep } from "@/lib/api";
import { Button } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
type Tab = "all" | "bash" | "powershell" | "default" | "shared";
const inputClass =
"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-signal focus:outline-none focus:ring-1 focus:ring-signal";
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
const isBash = interpreter === "bash";
return (
<span className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"}`}>
{isBash ? "bash" : "pwsh"}
</span>
);
}
export default function StepsPage() {
const qc = useQueryClient();
const { data: steps } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
const [search, setSearch] = useState("");
const [tab, setTab] = useState<Tab>("all");
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<WorkflowStep | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const rows = useMemo(() => {
const q = search.toLowerCase();
return (steps ?? []).filter((s) => {
const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
const matchesTab =
tab === "all" ||
(tab === "bash" && s.interpreter === "bash") ||
(tab === "powershell" && s.interpreter === "powershell") ||
(tab === "default" && s.source === "default") ||
(tab === "shared" && s.source !== "default");
return matchesText && matchesTab;
});
}, [steps, search, tab]);
const openNew = () => {
setEditing(null);
setEditOpen(true);
};
const openEdit = (s: WorkflowStep) => {
setEditing(s);
setEditOpen(true);
};
const onImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
await api.importStep(doc);
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
} catch (err) {
setError((err as Error).message);
} finally {
setImporting(false);
e.target.value = "";
}
};
const onSync = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
qc.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
return (
<div className="p-8">
<div className="mb-6 flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold text-text-primary">Steps</h1>
<p className="text-sm text-text-secondary">Reusable steps shared across all workflows.</p>
</div>
<div className="ml-auto flex items-center gap-2">
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={onImport} />
<Button variant="secondary" size="sm" loading={syncing} onClick={onSync}>
Sync defaults
</Button>
<Button variant="secondary" size="sm" loading={importing} onClick={() => fileRef.current?.click()}>
Import
</Button>
<Button size="sm" onClick={openNew}>
+ New step
</Button>
</div>
</div>
{error && <div className="mb-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="mb-4 rounded border border-signal/30 bg-signal/10 px-3 py-2 text-sm text-signal">{notice}</div>}
<div className="mb-4 flex items-center gap-2">
<input className={`${inputClass} max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-1.5">
{(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full border px-3 py-1 text-xs capitalize ${
tab === t ? "border-signal/50 bg-signal/15 text-signal" : "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{t === "powershell" ? "PowerShell" : t}
</button>
))}
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-text-secondary">
<th className="px-4 py-2.5 font-bold">Name</th>
<th className="px-4 py-2.5 font-bold">Shell</th>
<th className="px-4 py-2.5 font-bold">Source</th>
<th className="px-4 py-2.5 font-bold">Outputs</th>
<th className="px-4 py-2.5 font-bold">Used by</th>
<th className="px-4 py-2.5 text-right font-bold">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const count = usage?.[s.step_id] ?? 0;
return (
<tr key={s.step_id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-text-primary">{s.name}</div>
{s.description && <div className="text-xs text-text-secondary">{s.description}</div>}
</td>
<td className="px-4 py-3">
<ShellBadge interpreter={s.interpreter} />
</td>
<td className="px-4 py-3">
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
{s.source === "default" ? "default" : "shared"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(s.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded border border-signal/35 px-1.5 py-0.5 font-mono text-[10px] text-signal">
{o}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-text-secondary">
{count === 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">
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
Edit
</button>
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
Export
</a>
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
</div>
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-text-secondary">
No steps found.
</td>
</tr>
)}
</tbody>
</table>
</div>
<EditStepModal
key={editing?.step_id ?? "new"}
open={editOpen}
step={editing}
onClose={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["step-usage"] });
}}
/>
</div>
);
}
+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
import { Button } from "@/components/ui";
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
const inputClass =
"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-signal focus:outline-none focus:ring-1 focus:ring-signal";
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
const isBash = interpreter === "bash";
return <span className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"}`}>{isBash ? "bash" : "pwsh"}</span>;
}
function AdhocBadge() {
return <span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">ad-hoc</span>;
}
// Stable snapshot of only the fields the editor controls. Excludes volatile
// server-echo fields (e.g. updated_at) that would otherwise change on every
// save and cause autosave to loop forever.
function snapshotOf(w: Workflow): string {
return JSON.stringify({
name: w.name,
target_server_ids: w.target_server_ids,
steps: w.steps,
});
}
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
const router = useRouter();
const [wf, setWf] = useState<Workflow | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [, setTick] = useState(0);
const savedSnapshotRef = useRef<string | null>(null);
const savingRef = useRef(false);
const wfRef = useRef<Workflow | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [importingInline, setImportingInline] = useState(false);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const { data: loaded } = useQuery({
queryKey: ["workflow", id],
queryFn: () => api.getWorkflow(id),
});
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: secretGroups } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
useEffect(() => {
if (loaded && !wf) {
setWf(loaded);
savedSnapshotRef.current = snapshotOf(loaded);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded]);
// Lazily fetch the keys for every secret group so the inspector's
// secret-ref checklist can offer "group/KEY" options.
useEffect(() => {
if (!secretGroups) return;
secretGroups.forEach((g: SecretGroupSummary) => {
if (groupKeys[g.group] !== undefined) return;
api.getSecretGroup(g.group)
.then((res) =>
setGroupKeys((prev) => ({
...prev,
[g.group]: res.secrets.map((s) => s.key),
})),
)
.catch(() => {
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secretGroups]);
// Keep a ref to the latest workflow so an in-flight save can tell whether
// the user edited again while the request was on the wire.
wfRef.current = wf;
// Autosave: debounce 800ms after any change to the workflow (step added,
// removed, reordered, or edited) and persist. Diffing the serialized state
// against the last saved snapshot skips no-op saves and the initial load.
// Must stay above the early return below so hook order is stable.
useEffect(() => {
if (!wf || savedSnapshotRef.current === null) return;
if (snapshotOf(wf) === savedSnapshotRef.current) return;
const t = setTimeout(() => {
save();
}, 800);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wf]);
// Re-render every 15s so the "Saved … ago" label stays current.
useEffect(() => {
if (!lastSaved) return;
const iv = setInterval(() => setTick((n) => n + 1), 15000);
return () => clearInterval(iv);
}, [lastSaved]);
if (!wf) {
return <div className="p-8 text-text-secondary">Loading</div>;
}
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
const selectedRef = selected !== null ? sortedSteps[selected] : null;
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const save = async () => {
// Never run two saves concurrently: a request in flight would race the
// next one. The finally block re-triggers if edits landed meanwhile.
if (savingRef.current) return;
const current = wfRef.current;
if (!current) return;
const snapshot = snapshotOf(current);
if (snapshot === savedSnapshotRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
const updated = await api.updateWorkflow(id, current);
if (!updated || !Array.isArray(updated.steps)) {
setError("Save failed: server returned an unexpected response.");
return;
}
if (wfRef.current && snapshotOf(wfRef.current) === snapshot) {
// Nothing changed while the request was in flight: adopt the
// server echo as the new saved baseline.
savedSnapshotRef.current = snapshotOf(updated);
setWf(updated);
} else {
// The user edited again mid-flight. Keep their newer state and
// mark only the SENT snapshot as saved, so the effect re-fires
// and persists the remaining changes.
savedSnapshotRef.current = snapshot;
}
setLastSaved(new Date());
} catch (e) {
setError((e as Error).message);
} finally {
savingRef.current = false;
setSaving(false);
// If edits arrived during the save (or a concurrent save was
// skipped), persist them on the next tick.
if (wfRef.current && snapshotOf(wfRef.current) !== savedSnapshotRef.current) {
setTimeout(() => save(), 0);
}
}
};
const run = async () => {
setRunning(true);
setError(null);
try {
const { run_id } = await api.runWorkflow(id);
router.push(`/workflows/${id}/runs/${run_id}`);
} catch (e) {
setError((e as Error).message);
setRunning(false);
}
};
const resequence = (steps: WorkflowStepRef[]) => steps.map((r, i) => ({ ...r, order: i }));
const insertLibStep = (stepId: string, pos: number) => {
const next = [...sortedSteps];
next.splice(pos, 0, { step_id: stepId, order: 0, on_failure: "stop", max_retries: 0 });
setWf({ ...wf, steps: resequence(next) });
};
const appendRef = (ref: WorkflowStepRef) => {
setWf({ ...wf, steps: resequence([...sortedSteps, ref]) });
};
const addAdhocStep = () => {
appendRef({
inline: {
step_id: "",
name: "New ad-hoc step",
description: "",
interpreter: "bash",
script: "",
declared_outputs: [],
declared_inputs: [],
secret_refs: [],
},
order: wf.steps.length,
on_failure: "stop",
max_retries: 0,
});
};
const moveStep = (from: number, pos: number) => {
const next = [...sortedSteps];
const [item] = next.splice(from, 1);
const target = from < pos ? pos - 1 : pos;
next.splice(target, 0, item);
setWf({ ...wf, steps: resequence(next) });
if (selected === from) setSelected(target);
else if (selected !== null) {
if (from < selected && target >= selected) setSelected(selected - 1);
else if (from > selected && target <= selected) setSelected(selected + 1);
}
};
const handleDrop = (e: React.DragEvent, pos: number) => {
e.preventDefault();
setDragOverZone(null);
const raw = e.dataTransfer.getData("text/plain");
if (!raw) return;
let payload: DragPayload;
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (payload.kind === "lib") {
insertLibStep(payload.stepId, pos);
} else if (payload.kind === "move") {
moveStep(payload.from, pos);
}
};
const updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
setWf({
...wf,
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
});
const updateInline = (idx: number, patch: Partial<WorkflowStep>) =>
setWf({
...wf,
steps: wf.steps.map((r, i) => (i === idx && r.inline ? { ...r, inline: { ...r.inline, ...patch } } : r)),
});
const removeStep = (idx: number) => {
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
setWf({ ...wf, steps: remaining });
setSelected(null);
};
const toggleSecretRef = (ref: string) => {
if (selectedIdxInWf === -1 || !selectedRef) return;
if (selectedRef.inline) {
const current = selectedRef.inline.secret_refs ?? [];
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
updateInline(selectedIdxInWf, { secret_refs: next });
return;
}
const current = selectedRef.overrides?.secret_refs ?? [];
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
};
const upstreamOutputsFor = (i: number) => Array.from(new Set(sortedSteps.slice(0, i).flatMap((r) => r.inline?.declared_outputs ?? libById(r.step_id)?.declared_outputs ?? [])));
const DropZone = ({ pos }: { pos: number }) => (
<div
className={`h-3 w-full transition-all ${dragOverZone === pos ? "h-8 rounded bg-signal/15 border border-dashed border-signal/50" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragOverZone(pos);
}}
onDragLeave={() => setDragOverZone((z) => (z === pos ? null : z))}
onDrop={(e) => handleDrop(e, pos)}
/>
);
return (
<>
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-text-secondary">Workflows /</span>
<span className="font-medium text-text-primary">{wf.name}</span>
<span className="text-text-secondary">· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{wf.target_server_ids.length} servers</span>
<Link href={`/workflows/${id}/runs`} className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary">
Runs
</Link>
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
Edit
</Button>
<Button size="sm" loading={running} onClick={run} className="bg-signal text-signal-ink border-transparent hover:bg-signal/90">
Run workflow
</Button>
</div>
</div>
{error && <div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>}
<div className="grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]">
{/* CENTER: canvas */}
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8">
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
<button
onClick={() => setPickerOpen(true)}
className="pointer-events-auto inline-flex items-center gap-2 rounded-[9px] bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-[0_6px_20px_rgba(245,165,36,0.28)] hover:bg-signal/90"
>
<span className="text-base leading-none">+</span> Add step
</button>
</div>
<div className="mx-auto flex w-[340px] flex-col items-center">
<DropZone pos={0} />
{sortedSteps.map((ref, i) => {
const lib = libById(ref.step_id);
const outs = upstreamOutputsFor(i);
const script = ref.inline?.script ?? ref.overrides?.script ?? lib?.script ?? "";
const wfIdx = wf.steps.indexOf(ref);
const isSelected = selected === i;
return (
<div key={wfIdx} className="w-full">
{i > 0 && (
<div className="flex flex-col items-center py-1">
<div className="h-[13px] w-0.5 bg-border" />
{outs.length > 0 && (
<div className="flex w-fit max-w-[300px] flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-signal/55 bg-surface px-3 py-1">
<span className="text-[10px] uppercase text-text-secondary">passes</span>
{outs.map((o) => (
<span key={o} className="rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
{o}
</span>
))}
</div>
)}
<div className="h-[13px] w-0.5 bg-border" />
</div>
)}
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i }));
}}
onClick={() => setSelected(i)}
className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">{i + 1}</span>
<span className="text-sm font-medium text-text-primary">{ref.inline?.name ?? lib?.name ?? ref.step_id}</span>
{ref.inline && <ShellBadge interpreter={ref.inline.interpreter} />}
{lib && !ref.inline && <ShellBadge interpreter={lib.interpreter} />}
{ref.inline && <AdhocBadge />}
</div>
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
{script.slice(0, 200)}
</pre>
</div>
<DropZone pos={i + 1} />
</div>
);
})}
{sortedSteps.length === 0 && (
<button
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, 0)}
onClick={() => setPickerOpen(true)}
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
>
+ Add your first step
</button>
)}
</div>
</main>
{/* RIGHT: inspector */}
<aside className="overflow-auto border-l border-border bg-surface p-4">
{selected === null || !selectedRef ? (
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
) : (
<div className="space-y-4">
<div>
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-text-secondary">Step {selected + 1} · Inspector</div>
<div className="flex items-center gap-2">
{selectedRef.inline && <ShellBadge interpreter={selectedRef.inline.interpreter} />}
{selectedLib && !selectedRef.inline && <ShellBadge interpreter={selectedLib.interpreter} />}
{selectedRef.inline && <AdhocBadge />}
<h2 className="text-sm font-bold text-text-primary">{selectedRef.inline?.name ?? selectedLib?.name ?? selectedRef.step_id}</h2>
</div>
</div>
{selectedRef.inline ? (
<div className="space-y-4 border-b border-border pb-4">
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={selectedRef.inline.name} onChange={(e) => updateInline(selectedIdxInWf, { name: e.target.value })} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
<select
className={inputClass}
value={selectedRef.inline.interpreter}
onChange={(e) =>
updateInline(selectedIdxInWf, {
interpreter: e.target.value as WorkflowStep["interpreter"],
})
}
>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
<textarea
className={`${inputClass} h-32 font-mono text-xs`}
value={selectedRef.inline.script}
onChange={(e) => updateInline(selectedIdxInWf, { script: e.target.value })}
/>
<p className="mt-1 text-xs text-text-secondary">
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps. Outputs are derived
automatically on save.
</p>
</div>
</div>
) : (
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
<textarea
className={`${inputClass} h-32 font-mono text-xs`}
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
overrides: { ...selectedRef.overrides, script: e.target.value },
})
}
/>
<p className="mt-1 text-xs text-text-secondary">
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
</p>
</div>
)}
{(selectedRef.inline?.declared_inputs ?? selectedLib?.declared_inputs ?? []).length > 0 && (
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs</label>
<div className="space-y-2">
{(selectedRef.inline?.declared_inputs ?? selectedLib?.declared_inputs ?? []).map((param) => (
<div key={param.name}>
<div className="mb-1 font-mono text-xs text-text-primary">{param.name}</div>
{param.description && <div className="mb-1 text-[11px] text-text-secondary">{param.description}</div>}
<input
className={inputClass}
placeholder={param.default}
value={selectedRef.inputs?.[param.name] ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
inputs: { ...selectedRef.inputs, [param.name]: e.target.value },
})
}
/>
</div>
))}
</div>
</div>
)}
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs · from upstream</label>
<div className="flex flex-wrap gap-1">
{upstreamOutputsFor(selected).length === 0 && <p className="text-xs text-text-secondary">No upstream outputs.</p>}
{upstreamOutputsFor(selected).map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-surface-2 border border-border px-2 py-0.5 font-mono text-[11px] text-text-primary">
<span className="text-[9px] uppercase text-text-secondary">in</span>
{o}
</span>
))}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Outputs · to $WORKFLOW_ENV</label>
<div className="flex flex-wrap gap-1">
{(selectedRef.inline?.declared_outputs ?? selectedLib?.declared_outputs ?? []).length === 0 && <p className="text-xs text-text-secondary">No declared outputs.</p>}
{(selectedRef.inline?.declared_outputs ?? selectedLib?.declared_outputs ?? []).map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
<span className="text-[9px] uppercase">out</span>
{o}
</span>
))}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Secret refs</label>
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
{secretGroups?.map((g) => (
<div key={g.group}>
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
{(groupKeys[g.group] ?? []).map((key) => {
const ref = `${g.group}/${key}`;
const checked = (selectedRef.inline ? (selectedRef.inline.secret_refs ?? []) : (selectedRef.overrides?.secret_refs ?? [])).includes(ref);
return (
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
<input type="checkbox" className="accent-signal" checked={checked} onChange={() => toggleSecretRef(ref)} />
<span className="font-mono">{key}</span>
</label>
);
})}
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
</div>
))}
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">On failure</label>
<select
className={inputClass}
value={selectedRef.on_failure}
onChange={(e) =>
updateRef(selectedIdxInWf, {
on_failure: e.target.value as WorkflowStepRef["on_failure"],
})
}
>
<option value="stop">Stop workflow</option>
<option value="continue">Continue</option>
<option value="retry">Retry</option>
</select>
{selectedRef.on_failure === "retry" && (
<div className="mt-2">
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
<input
type="number"
min={1}
className={inputClass}
value={selectedRef.max_retries}
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
/>
</div>
)}
</div>
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
Remove from workflow
</Button>
</div>
)}
</aside>
</div>
<EditWorkflowModal
open={editWorkflowOpen}
workflow={wf}
onSaved={(w) => {
// The modal already persisted w; sync the snapshot so
// autosave doesn't fire a redundant follow-up save.
savedSnapshotRef.current = snapshotOf(w);
setWf(w);
}}
onClose={() => setEditWorkflowOpen(false)}
/>
<StepPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(stepId) => insertLibStep(stepId, sortedSteps.length)}
onAddAdhoc={() => {
addAdhocStep();
setPickerOpen(false);
}}
onImportAdhoc={async (file) => {
setPickerOpen(false);
setImportingInline(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
const step = await api.parseStep(doc);
appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 });
} catch (err) {
setError((err as Error).message);
} finally {
setImportingInline(false);
}
}}
/>
</>
);
}
@@ -0,0 +1,442 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
import { Button } from "@/components/ui";
// ---- status vocabulary ----------------------------------------------------
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
function cellKind(status: string): CellKind {
switch (status) {
case "success":
return "done";
case "failed":
return "fail";
case "running":
return "run";
case "skipped":
return "skip";
case "cancelled":
return "warn";
default:
return "wait"; // queued / pending / missing
}
}
const cellGlyph: Record<CellKind, string> = {
done: "✓",
fail: "✕",
run: "●",
wait: "○",
skip: "",
warn: "!",
};
const cellClass: Record<CellKind, string> = {
done: "bg-success/15 text-success",
fail: "bg-danger/15 text-danger",
run: "bg-accent/15 text-accent",
wait: "text-border",
skip: "text-text-secondary",
warn: "bg-warning/15 text-warning",
};
// ---- run-level status pill ------------------------------------------------
type PillKind = "running" | "success" | "failed" | "neutral";
function pillKind(status: string): PillKind {
if (status === "running") return "running";
if (status === "success") return "success";
if (status === "failed" || status === "cancelled") return "failed";
return "neutral";
}
const pillClass: Record<PillKind, string> = {
running: "text-accent border-accent/40 bg-accent/10",
success: "text-success border-success/35 bg-success/10",
failed: "text-danger border-danger/35 bg-danger/10",
neutral: "text-text-secondary border-border bg-surface-2",
};
const pillLed: Record<PillKind, string> = {
running: "bg-accent led-pulse",
success: "bg-success",
failed: "bg-danger",
neutral: "bg-text-secondary",
};
function StatusPill({ status, small }: { status: string; small?: boolean }) {
const kind = pillKind(status);
return (
<span
className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${
small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
} ${pillClass[kind]}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${pillLed[kind]}`} />
{status}
</span>
);
}
// ---- time helpers ---------------------------------------------------------
function fmtDuration(ms: number): string {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function stepDuration(st: StepRun, running: boolean, now: number): string {
if (!st.started_at) return st.status === "queued" ? "queued" : "";
const start = new Date(st.started_at).getTime();
const end = st.finished_at ? new Date(st.finished_at).getTime() : running ? now : start;
return fmtDuration(end - start);
}
// ---- live log terminal ----------------------------------------------------
function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
const [text, setText] = useState("");
const preRef = useRef<HTMLDivElement>(null);
const running = server.status === "running";
const serverId = server.server_id;
useEffect(() => {
setText("");
if (running) {
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), {
withCredentials: true,
});
es.onmessage = (e) => setText((t) => t + e.data + "\n");
es.addEventListener("done", () => es.close());
es.onerror = () => es.close();
return () => es.close();
}
api.getServerRunLog(runId, serverId)
.then(setText)
.catch(() => setText(""));
}, [running, runId, serverId]);
useEffect(() => {
preRef.current?.scrollTo(0, preRef.current.scrollHeight);
}, [text]);
const activeStep = server.steps.find((s) => s.status === "running") ?? [...server.steps].reverse().find((s) => s.started_at);
return (
<div className="overflow-hidden rounded-xl border border-border bg-[#0a0b10]">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface px-4 py-3">
<span className="truncate font-mono text-[13px] font-semibold text-text-primary">
{activeStep ? activeStep.name : "Output"} <span className="font-normal text-text-secondary">{server.hostname}</span>
</span>
{running && (
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-wide text-accent">
<span className="h-1.5 w-1.5 rounded-full bg-accent led-pulse" />
Streaming
</span>
)}
</div>
<div ref={preRef} className="max-h-[340px] overflow-auto whitespace-pre-wrap px-4 py-3.5 font-mono text-[12.5px] leading-relaxed text-text-secondary">
{text ? <LogLines text={text} /> : running ? "Waiting for output…" : "No output."}
{running && text && <span className="ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] bg-accent caret-blink align-baseline" />}
</div>
</div>
);
}
// LogLines renders the raw server-run log, parsing each line's leading UTC
// timestamp ([2026-07-20T12:04:02.000Z]) and rendering it in the viewer's local
// timezone. Event markers (===== …) are highlighted so the run's shape scans.
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
function LogLines({ text }: { text: string }) {
const lines = text.replace(/\n$/, "").split("\n");
return (
<>
{lines.map((line, i) => {
const m = TS_RE.exec(line);
if (!m) {
return (
<span key={i} className="block">
{line || " "}
</span>
);
}
const local = new Date(m[1]).toLocaleTimeString([], { hour12: false });
const body = m[2];
const isMarker = body.startsWith("=====");
return (
<span key={i} className="block">
<span className="select-none text-[#565b74]" title={m[1]}>
{local}{" "}
</span>
<span className={isMarker ? "font-semibold text-accent" : ""}>{body || " "}</span>
</span>
);
})}
</>
);
}
// ---- step list ------------------------------------------------------------
function StepList({ server, now }: { server: ServerRun; now: number }) {
const running = server.status === "running";
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<span className="font-mono text-[13px] font-semibold text-text-primary">
Steps <span className="font-normal text-text-secondary">{server.steps.length}</span>
</span>
<StatusPill status={server.status} small />
</div>
<div className="flex flex-col gap-0.5 p-1.5">
{server.steps.map((st) => {
const kind = cellKind(st.status);
return (
<div
key={st.order}
className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${st.status === "running" ? "bg-accent/[0.06]" : ""}`}
>
<span className="text-right font-mono text-[11px] text-text-secondary">{String(st.order + 1).padStart(2, "0")}</span>
<span className="flex items-center gap-2 font-medium text-text-primary">
<span className={`font-mono ${cellClass[kind].replace(/bg-\S+/, "")}`}>{cellGlyph[kind]}</span>
{st.name}
</span>
<span className="text-right font-mono text-[10.5px] text-text-secondary">
{st.status === "failed" && <span className="text-danger">exit {st.exit_code} · </span>}
{st.attempts > 1 ? `${st.attempts} tries` : "1 try"}
{stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""}
</span>
</div>
);
})}
{server.steps.length === 0 && <p className="px-3 py-2 text-xs text-text-secondary">No steps yet.</p>}
</div>
</div>
);
}
// ---- execution matrix (signature) -----------------------------------------
interface Column {
order: number;
name: string;
}
function buildColumns(run: WorkflowRun): Column[] {
const byOrder = new Map<number, string>();
for (const sr of run.server_runs) {
for (const st of sr.steps) {
if (!byOrder.has(st.order)) byOrder.set(st.order, st.name);
}
}
return [...byOrder.entries()].map(([order, name]) => ({ order, name })).sort((a, b) => a.order - b.order);
}
function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRun; columns: Column[]; selected: string; onSelect: (serverId: string) => void }) {
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse font-mono text-[12.5px]">
<thead>
<tr>
<th className="border-b border-border px-4 py-3 text-left align-bottom text-xs font-semibold uppercase tracking-wider text-text-primary">Server</th>
{columns.map((c) => (
<th key={c.order} className="whitespace-nowrap border-b border-border px-3.5 py-3 align-bottom text-[11px] font-medium text-text-secondary">
<span className="block text-[10px] text-border">{String(c.order + 1).padStart(2, "0")}</span>
{c.name}
</th>
))}
</tr>
</thead>
<tbody>
{run.server_runs.map((sr) => {
const byOrder = new Map(sr.steps.map((s) => [s.order, s]));
const isSel = sr.server_id === selected;
return (
<tr key={sr.server_id} onClick={() => onSelect(sr.server_id)} className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`}>
<th className="min-w-[240px] border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
<div className="flex items-center gap-3">
<span className="flex-1 whitespace-nowrap">{sr.hostname}</span>
<StatusPill status={sr.status} small />
</div>
</th>
{columns.map((c) => {
const st = byOrder.get(c.order);
const kind = st ? cellKind(st.status) : "wait";
return (
<td key={c.order} className="relative border-b border-r border-border last:border-r-0">
<span className="flex h-[54px] items-center justify-center">
<span className={`relative flex h-[26px] w-[26px] items-center justify-center rounded-md ${cellClass[kind]}`}>
{kind === "run" && <span className="absolute inset-0 rounded-md border border-accent/50 cell-ring" />}
{cellGlyph[kind]}
</span>
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ---- page -----------------------------------------------------------------
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">
{children}
<span className="h-px flex-1 bg-border" />
</div>
);
}
export default function RunDetail() {
const { runId } = useParams<{ runId: string }>();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const { data: run, isLoading } = useQuery({
queryKey: ["run", runId],
queryFn: () => api.getRun(runId),
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
});
const running = run?.status === "running";
// tick the elapsed clock while running
useEffect(() => {
if (!running) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [running]);
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
// default selection: first running server, else first server
const selectedServer = useMemo(() => {
if (!run || run.server_runs.length === 0) return null;
if (selected) {
const match = run.server_runs.find((s) => s.server_id === selected);
if (match) return match;
}
return run.server_runs.find((s) => s.status === "running") ?? run.server_runs[0];
}, [run, selected]);
const cancel = async () => {
await api.cancelRun(runId);
queryClient.invalidateQueries({ queryKey: ["run", runId] });
};
if (isLoading || !run) {
return <div className="p-8 text-text-secondary">Loading</div>;
}
const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0);
const doneSteps = run.server_runs.reduce((n, s) => n + s.steps.filter((st) => st.status === "success").length, 0);
const succeeded = run.server_runs.filter((s) => s.status === "success").length;
const failed = run.server_runs.filter((s) => s.status === "failed" || s.status === "cancelled").length;
const startMs = run.started_at ? new Date(run.started_at).getTime() : now;
const endMs = run.finished_at ? new Date(run.finished_at).getTime() : now;
const elapsed = fmtDuration(endMs - startMs);
const ago = fmtDuration(now - startMs);
return (
<div className="mx-auto max-w-[1180px] p-8 pb-16">
{/* identity bar */}
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<div className="mb-2 font-mono text-xs uppercase tracking-wide text-text-secondary">Workflows / {run.name} / Runs</div>
<h1 className="text-[28px] font-semibold tracking-tight text-text-primary">{run.name}</h1>
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-[12.5px] text-text-secondary">
<span>
run <b className="font-medium text-text-primary">{run.run_id.slice(0, 8)}</b>
</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>
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
started <b className="font-medium text-text-primary">{ago}</b> ago
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
elapsed <b className="font-medium text-text-primary">{elapsed}</b>
</span>
</div>
</div>
<div className="flex items-center gap-3.5">
<StatusPill status={run.status} />
{running && (
<Button variant="danger" onClick={cancel}>
Cancel run
</Button>
)}
</div>
</div>
{/* summary strip */}
<div className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Servers</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">{run.server_runs.length}</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Succeeded</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-success">
{succeeded}
<small className="text-sm font-medium text-text-secondary"> / {run.server_runs.length}</small>
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Failed</div>
<div className={`mt-1 font-mono text-[22px] font-semibold tabular-nums ${failed > 0 ? "text-danger" : "text-text-primary"}`}>{failed}</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Steps done</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
{doneSteps}
<small className="text-sm font-medium text-text-secondary"> / {totalSteps}</small>
</div>
</div>
</div>
{run.server_runs.length === 0 ? (
<p className="mt-8 text-text-secondary">No servers targeted by this run.</p>
) : (
<>
<SectionLabel>Execution matrix</SectionLabel>
<ExecutionMatrix run={run} columns={columns} selected={selectedServer?.server_id ?? ""} onSelect={setSelected} />
{selectedServer && (
<>
<SectionLabel>{selectedServer.hostname}&nbsp;·&nbsp;steps &amp; live output</SectionLabel>
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-[320px_1fr]">
<StepList server={selectedServer} now={now} />
<LogTerminal runId={run.run_id} server={selectedServer} />
</div>
</>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, WorkflowRun } from "@/lib/api";
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
const statusVariant: Record<string, BadgeVariant> = {
success: "success",
failed: "danger",
running: "warning",
cancelled: "neutral",
queued: "neutral",
};
export default function WorkflowRunsPage() {
const { id } = useParams<{ id: string }>();
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
const { data: runs, isLoading, error } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
return (
<div className="p-8">
<div className="mb-6">
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">
Back to builder
</Link>
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
</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 runs. Is the backend running?</div>
) : runs && runs.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Run</Th>
<Th>Status</Th>
<Th>Started</Th>
<Th>By</Th>
<Th>Servers</Th>
</Tr>
</Thead>
<Tbody>
{runs.map((r: WorkflowRun) => (
<Tr key={r.run_id}>
<Td>
<Link
href={`/workflows/${id}/runs/${r.run_id}`}
className="font-mono text-text-primary hover:text-signal"
>
{r.run_id.slice(0, 8)}
</Link>
</Td>
<Td>
<Badge variant={statusVariant[r.status] ?? "neutral"}>{r.status}</Badge>
</Td>
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
<Td className="text-text-secondary">{r.triggered_by}</Td>
<Td className="text-text-secondary">{r.server_runs.length}</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
)}
</Card>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
export default function WorkflowsPage() {
const qc = useQueryClient();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const { data: workflows, isLoading, error: loadError } = useQuery({
queryKey: ["workflows"],
queryFn: api.listWorkflows,
});
const { mutate: create, isPending } = useMutation({
mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }),
onSuccess: (workflow) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
router.push(`/workflows/${workflow.workflow_id}`);
},
onError: (err) => setError((err as Error).message),
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Workflows</h1>
<p className="mt-1 text-sm text-text-secondary">
{workflows?.length ?? 0} workflow{workflows?.length !== 1 ? "s" : ""} · run reusable steps across servers
</p>
</div>
<Button variant="primary" loading={isPending} onClick={() => create()}>
<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>
New Workflow
</Button>
</div>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{error}
</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>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
) : workflows && workflows.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Targets</Th>
<Th>Steps</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{workflows.map((w: Workflow) => (
<Tr key={w.workflow_id}>
<Td>
<span className="font-medium text-text-primary">{w.name}</span>
</Td>
<Td>
<span className="text-text-secondary">
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary">{w.steps.length}</span>
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Link href={`/workflows/${w.workflow_id}/runs`}>
<Button variant="ghost" size="sm">Runs</Button>
</Link>
<Link href={`/workflows/${w.workflow_id}`}>
<Button variant="ghost" size="sm">Open </Button>
</Link>
</div>
</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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-text-secondary">No workflows yet.</p>
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
Create your first workflow
</Button>
</div>
)}
</Card>
</div>
);
}