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:
co-authored by
Claude Opus 4.8
parent
ec7e3e706f
commit
1931500c24
@@ -1,6 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Network as NetworkIcon, Plus, Trash2, Eraser } from "lucide-react";
|
||||
import {
|
||||
Network as NetworkIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eraser,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Link2,
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
@@ -21,7 +30,9 @@ export function Networks() {
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
||||
const colSpan = isAdmin ? 6 : 5;
|
||||
|
||||
const prune = useMutation({
|
||||
mutationFn: networksApi.prune,
|
||||
@@ -71,9 +82,18 @@ export function Networks() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{data?.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<Fragment key={n.id}>
|
||||
<tr
|
||||
onClick={() => setExpanded((e) => (e === n.id ? null : n.id))}
|
||||
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded === n.id ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<NetworkIcon className="h-4 w-4 text-slate-400" />
|
||||
<span className="font-medium">{n.name}</span>
|
||||
{n.is_default && <Badge>default</Badge>}
|
||||
@@ -98,7 +118,7 @@ export function Networks() {
|
||||
{!n.is_default && (
|
||||
<button
|
||||
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
|
||||
onClick={() => setToDelete(n)}
|
||||
onClick={(e) => { e.stopPropagation(); setToDelete(n); }}
|
||||
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
@@ -107,10 +127,18 @@ export function Networks() {
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{expanded === n.id && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
|
||||
<NetworkDetail network={n} isAdmin={isAdmin} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-500">
|
||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
|
||||
No networks.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -141,6 +169,114 @@ export function Networks() {
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkDetail({ network, isAdmin }: { network: NetworkInfo; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [pick, setPick] = useState("");
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["network-containers", network.id],
|
||||
queryFn: () => networksApi.containers(network.id),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
|
||||
qc.invalidateQueries({ queryKey: ["networks"] });
|
||||
};
|
||||
|
||||
const connect = useMutation({
|
||||
mutationFn: (container: string) => networksApi.connect(network.id, container),
|
||||
onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (container: string) => networksApi.disconnect(network.id, container),
|
||||
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const connected = (data ?? []).filter((c) => c.connected);
|
||||
const available = (data ?? []).filter((c) => !c.connected);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
|
||||
<Meta label="Driver" value={network.driver} />
|
||||
<Meta label="Scope" value={network.scope} />
|
||||
<Meta label="Subnet" value={network.subnet ?? "—"} mono />
|
||||
<Meta label="Gateway" value={network.gateway ?? "—"} mono />
|
||||
<Meta label="Attachable" value={network.attachable ? "yes" : "no"} />
|
||||
<Meta label="Internal" value={network.internal ? "yes" : "no"} />
|
||||
<Meta label="ID" value={network.id} mono />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Connected containers
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : connected.length === 0 ? (
|
||||
<p className="text-slate-400">No containers connected.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{connected.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-2">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.stack && <Badge>{c.stack}</Badge>}
|
||||
<span className="text-xs text-slate-400">{c.state}</span>
|
||||
{isAdmin && !network.is_default && (
|
||||
<button
|
||||
title="Disconnect"
|
||||
onClick={() => disconnect.mutate(c.name)}
|
||||
disabled={disconnect.isPending}
|
||||
className="ml-1 rounded p-1 hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className={selectClass + " max-w-xs"}
|
||||
value={pick}
|
||||
onChange={(e) => setPick(e.target.value)}
|
||||
>
|
||||
<option value="">Connect a container…</option>
|
||||
{available.map((c) => (
|
||||
<option key={c.id} value={c.name}>
|
||||
{c.name}
|
||||
{c.stack ? ` (${c.stack})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!pick || connect.isPending}
|
||||
loading={connect.isPending}
|
||||
onClick={() => connect.mutate(pick)}
|
||||
>
|
||||
<Link2 className="h-4 w-4" /> Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs text-slate-400">{label}</span>
|
||||
<p className={mono ? "font-mono text-xs" : ""}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export function Placeholder({ title, phase }: { title: string; phase: string }) {
|
||||
return (
|
||||
<Card className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<Construction className="h-10 w-10 text-slate-400" />
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="max-w-md text-sm text-slate-500">
|
||||
This section is part of {phase}. The backend foundation is ready — the UI
|
||||
lands in an upcoming build phase.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const Networks = () => <Placeholder title="Networks" phase="a future phase" />;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -116,7 +117,11 @@ export function RemoteStackDetail() {
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === "Overview" && <Overview containers={data.containers} />}
|
||||
{tab === "Logs" && <RemoteLogs agentId={aid} stackId={id} />}
|
||||
{tab === "Logs" && (
|
||||
<Card className="h-full overflow-hidden">
|
||||
<LogViewer stackId={id} agentId={aid} />
|
||||
</Card>
|
||||
)}
|
||||
{tab === "Environment" && (
|
||||
<RemoteEditor
|
||||
agentId={aid}
|
||||
@@ -169,30 +174,6 @@ function Overview({ containers }: { containers: any[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteLogs({ agentId, stackId }: { agentId: number; stackId: string }) {
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["agent-logs", agentId, stackId],
|
||||
queryFn: () => agentsApi.logs(agentId, stackId, 400),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
return (
|
||||
<Card className="flex h-full flex-col overflow-hidden">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<Button variant="ghost" onClick={() => refetch()} loading={isFetching}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-all bg-slate-950 p-3 font-mono text-xs text-slate-100">
|
||||
{data?.logs || "No logs."}
|
||||
</pre>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteEditor({
|
||||
agentId,
|
||||
stackId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||
import { EnvEditor } from "@/components/env/EnvEditor";
|
||||
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { portsApi, type PortConflict } from "@/api/ports";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
@@ -38,6 +39,7 @@ export function StackEditor() {
|
||||
const [runCmd, setRunCmd] = useState("");
|
||||
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [host, setHost] = useState("local");
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ["stack", id],
|
||||
@@ -45,6 +47,14 @@ export function StackEditor() {
|
||||
enabled: !isNew,
|
||||
});
|
||||
|
||||
const agents = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list(),
|
||||
enabled: isNew,
|
||||
});
|
||||
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
|
||||
const remote = isNew && host !== "local";
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
setName(existing.data.name);
|
||||
@@ -61,6 +71,21 @@ export function StackEditor() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
// Remote host: create the stack on the agent, then optionally start it.
|
||||
if (remote) {
|
||||
const aid = Number(host);
|
||||
const created = await agentsApi.createStack(aid, { name, yaml, env });
|
||||
qc.invalidateQueries({ queryKey: ["agent-stacks", aid] });
|
||||
toast.success("Saved");
|
||||
if (deploy) {
|
||||
const t = toast.loading("Deploying…");
|
||||
await agentsApi.action(aid, created.id, "start");
|
||||
toast.success("Deployed ✓", { id: t });
|
||||
}
|
||||
navigate(`/hosts/${aid}/stacks/${created.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let stackId = id;
|
||||
if (isNew) {
|
||||
const created = await stacksApi.create({ name, description, yaml, env });
|
||||
@@ -85,6 +110,11 @@ export function StackEditor() {
|
||||
};
|
||||
|
||||
const onDeploy = async () => {
|
||||
// The local port-conflict check doesn't apply to remote hosts.
|
||||
if (remote) {
|
||||
save(true);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
try {
|
||||
const found = await portsApi.conflicts(yaml, id);
|
||||
@@ -128,6 +158,21 @@ export function StackEditor() {
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{isNew && onlineAgents.length > 0 && (
|
||||
<select
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
title="Target host"
|
||||
>
|
||||
<option value="local">This host</option>
|
||||
{onlineAgents.map((a) => (
|
||||
<option key={a.id} value={String(a.id)}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
|
||||
<Wand2 className="h-4 w-4" /> Convert docker run
|
||||
</Button>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user