166 lines
13 KiB
TypeScript
166 lines
13 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||
|
|
import { Loader2, Plus, Wrench, X } from "lucide-react";
|
||
|
|
import { useParams } from "next/navigation";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
import type { HarnessType } from "@tasks/shared/worker";
|
||
|
|
|
||
|
|
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
|
||
|
|
|
||
|
|
type HarnessForm = {
|
||
|
|
alias: string;
|
||
|
|
displayName: string;
|
||
|
|
harnessType: HarnessType;
|
||
|
|
endpointUrl: string;
|
||
|
|
authEnvVar: string;
|
||
|
|
enabled: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
const EMPTY_FORM: HarnessForm = {
|
||
|
|
alias: "",
|
||
|
|
displayName: "",
|
||
|
|
harnessType: "minimal",
|
||
|
|
endpointUrl: "",
|
||
|
|
authEnvVar: "",
|
||
|
|
enabled: true,
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function HarnessesSettingsPage() {
|
||
|
|
const params = useParams();
|
||
|
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||
|
|
const utils = api.useUtils();
|
||
|
|
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||
|
|
const routesQuery = api.harnesses.listRoutes.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||
|
|
|
||
|
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||
|
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||
|
|
const [form, setForm] = React.useState<HarnessForm>(EMPTY_FORM);
|
||
|
|
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||
|
|
const [newRoleId, setNewRoleId] = React.useState("");
|
||
|
|
const [newHarnessId, setNewHarnessId] = React.useState("");
|
||
|
|
|
||
|
|
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||
|
|
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||
|
|
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
|
||
|
|
const testMut = api.harnesses.testHarness.useMutation({
|
||
|
|
onSuccess: (result, vars) => {
|
||
|
|
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
|
||
|
|
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
|
||
|
|
|
||
|
|
const canManage = listQuery.data?.canManage ?? false;
|
||
|
|
const harnesses = listQuery.data?.harnesses ?? [];
|
||
|
|
const routes = routesQuery.data?.routes ?? [];
|
||
|
|
|
||
|
|
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
|
||
|
|
const openEdit = (h: (typeof harnesses)[number]) => {
|
||
|
|
setEditingId(h.id);
|
||
|
|
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
|
||
|
|
setDialogOpen(true);
|
||
|
|
};
|
||
|
|
const submit = () => {
|
||
|
|
if (!workspaceSlug) return;
|
||
|
|
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.trim() || null, enabled: form.enabled };
|
||
|
|
if (editingId) updateMut.mutate({ ...payload, id: editingId });
|
||
|
|
else createMut.mutate(payload);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!workspaceSlug) return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-5xl px-8 py-10">
|
||
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"><Wrench className="size-5 text-primary" /></div>
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-semibold">Harnesses</h1>
|
||
|
|
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{listQuery.isLoading ? <div className="flex justify-center py-16"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>
|
||
|
|
: listQuery.error ? <p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||
|
|
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</div>
|
||
|
|
: (
|
||
|
|
<div className="overflow-hidden rounded-lg border">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||
|
|
<tr><th className="px-4 py-2 text-left font-medium">Alias</th><th className="px-4 py-2 text-left font-medium">Type</th><th className="px-4 py-2 text-left font-medium">Endpoint</th><th className="px-4 py-2 text-left font-medium">On</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody className="divide-y">
|
||
|
|
{harnesses.map((h) => (
|
||
|
|
<tr key={h.id} className="hover:bg-muted/20">
|
||
|
|
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
|
||
|
|
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
|
||
|
|
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
|
||
|
|
<td className="px-4 py-2 text-xs">{h.enabled ? "yes" : "no"}</td>
|
||
|
|
{canManage ? (
|
||
|
|
<td className="space-x-2 px-4 py-2 text-right">
|
||
|
|
<Button variant="outline" size="sm" disabled={testMut.isPending} onClick={() => testMut.mutate({ workspace: workspaceSlug, harnessId: h.id })}>Test</Button>
|
||
|
|
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
|
||
|
|
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
|
||
|
|
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
|
||
|
|
</td>
|
||
|
|
) : null}
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="mt-10 rounded-lg border">
|
||
|
|
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
|
||
|
|
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
|
||
|
|
: (
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground"><tr><th className="px-4 py-2 text-left font-medium">Role</th><th className="px-4 py-2 text-left font-medium">Harness</th><th className="px-4 py-2 text-left font-medium">Type</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr></thead>
|
||
|
|
<tbody className="divide-y">
|
||
|
|
{routes.length === 0 ? <tr><td colSpan={canManage ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
|
||
|
|
: routes.map((r) => (
|
||
|
|
<tr key={r.id} className="hover:bg-muted/20">
|
||
|
|
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
|
||
|
|
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
|
||
|
|
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</td>
|
||
|
|
{canManage ? <td className="px-4 py-2 text-right"><select className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs" value={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
)}
|
||
|
|
{canManage && harnesses.length > 0 ? (
|
||
|
|
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
|
||
|
|
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
|
||
|
|
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
|
||
|
|
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
|
|
<DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" /><DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||
|
|
<div className="mb-4 flex items-center justify-between"><DialogPrimitive.Title className="text-lg font-semibold">{editingId ? "Edit harness" : "Add harness"}</DialogPrimitive.Title><DialogPrimitive.Close asChild><button type="button" className="rounded-md p-1 hover:bg-muted"><X className="size-4" /></button></DialogPrimitive.Close></div>
|
||
|
|
<div className="space-y-3">
|
||
|
|
<Field label="Alias"><Input value={form.alias} onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))} /></Field>
|
||
|
|
<Field label="Display name"><Input value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} /></Field>
|
||
|
|
<Field label="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
|
||
|
|
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
|
||
|
|
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</p></Field>
|
||
|
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} /> Enabled</label>
|
||
|
|
</div>
|
||
|
|
<div className="mt-6 flex justify-end gap-2"><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={submit} disabled={createMut.isPending || updateMut.isPending}>{createMut.isPending || updateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Save"}</Button></div>
|
||
|
|
</DialogPrimitive.Content></DialogPrimitive.Portal>
|
||
|
|
</DialogPrimitive.Root>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div><label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>{children}</div>; }
|