Phase 11: remote UX & network attach (0.11.0)

- 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>
This commit is contained in:
menzelj
2026-06-08 07:49:20 +00:00
co-authored by Claude Opus 4.8
parent ec7e3e706f
commit 1931500c24
18 changed files with 492 additions and 67 deletions
+25 -2
View File
@@ -4,10 +4,14 @@ 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);
@@ -78,8 +82,12 @@ function UseTemplateDialog({
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");
@@ -87,9 +95,11 @@ function UseTemplateDialog({
}
setBusy(true);
try {
const res = await templatesApi.instantiate(template.id, name, values);
const agentId = host === "local" ? null : Number(host);
const res = await templatesApi.instantiate(template.id, name, values, agentId);
toast.success(`Stack '${res.name}' created`);
navigate(`/stacks/${res.id}/edit`);
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 {
@@ -106,6 +116,19 @@ function UseTemplateDialog({
<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">