- Live remote-stack logs over a WebSocket proxied through the central app to
the agent (/ws/agent-logs/{agent}/{stack}); agent gains a WS log endpoint.
- Deploy to a remote host from the UI: host selector in the New Stack editor
and template dialog; templates instantiate onto an agent via the proxy.
- Network attach/detach: expandable inspect view per network with
connect/disconnect + container picker; GET /{id}/containers, POST connect/disconnect.
- Remove dead pages/Placeholder.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { LayoutTemplate, Cpu, Package } from "lucide-react";
|
|
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
|
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { useAuthStore } from "@/store/auth";
|
|
import { toast } from "sonner";
|
|
|
|
const selectClass =
|
|
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
|
|
|
|
export function Templates() {
|
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
|
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
|
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
|
|
|
const open = async (t: TemplateSummary) => {
|
|
try {
|
|
setSelected(await templatesApi.get(t.id));
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{data?.map((t) => (
|
|
<Card key={t.id} className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{t.source === "custom" ? (
|
|
<Package className="h-5 w-5 text-accent dark:text-accent-dark" />
|
|
) : (
|
|
<LayoutTemplate className="h-5 w-5 text-accent dark:text-accent-dark" />
|
|
)}
|
|
<span className="font-semibold">{t.name}</span>
|
|
{t.source === "custom" && <Badge>custom</Badge>}
|
|
</div>
|
|
{t.description && <p className="text-sm text-slate-500">{t.description}</p>}
|
|
<div className="flex flex-wrap gap-1">
|
|
{t.tags.map((tag) => (
|
|
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{t.gpu && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
|
<Cpu className="h-3 w-3" /> {t.gpu}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{isAdmin && (
|
|
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
|
|
Use template
|
|
</Button>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{selected && (
|
|
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UseTemplateDialog({
|
|
template,
|
|
onClose,
|
|
}: {
|
|
template: TemplateDetail;
|
|
onClose: () => void;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const [name, setName] = useState(template.name);
|
|
const [values, setValues] = useState<Record<string, string>>(
|
|
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
|
|
);
|
|
const [host, setHost] = useState("local");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list() });
|
|
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
|
|
|
|
const create = async () => {
|
|
if (!name.trim()) {
|
|
toast.error("Stack name required");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const agentId = host === "local" ? null : Number(host);
|
|
const res = await templatesApi.instantiate(template.id, name, values, agentId);
|
|
toast.success(`Stack '${res.name}' created`);
|
|
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
|
|
else navigate(`/stacks/${res.id}/edit`);
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
|
<h2 className="mb-3 text-lg font-semibold">Use “{template.name}”</h2>
|
|
<div className="space-y-3">
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Stack name</span>
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
</label>
|
|
{onlineAgents.length > 0 && (
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Deploy to host</span>
|
|
<select className={selectClass} value={host} onChange={(e) => setHost(e.target.value)}>
|
|
<option value="local">This host</option>
|
|
{onlineAgents.map((a) => (
|
|
<option key={a.id} value={String(a.id)}>
|
|
{a.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
{template.variables.map((v) => (
|
|
<label key={v.name} className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">
|
|
{v.name}
|
|
{v.description && <span className="ml-1 font-normal text-slate-400">— {v.description}</span>}
|
|
</span>
|
|
<Input
|
|
value={values[v.name] ?? ""}
|
|
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
|
<Button onClick={create} loading={busy}>Create stack</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|