Files
huskies/frontend/src/components/GatewayPanel.tsx
T

520 lines
13 KiB
TypeScript
Raw Normal View History

/// Gateway management panel shown when huskies runs in `--gateway` mode.
///
/// Provides:
/// - An "Add Agent" button that generates a one-time join token.
/// - Instructions for running a build agent with the token.
/// - A list of connected agents with per-agent project assignment and "Remove" buttons.
import * as React from "react";
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
const { useCallback, useEffect, useState } = React;
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
const envCmd = `HUSKIES_JOIN_TOKEN=${token} huskies agent --rendezvous <CRDT_SYNC_URL>`;
const flagCmd = `huskies agent --rendezvous <CRDT_SYNC_URL> --join-token ${token}`;
const copyToClipboard = useCallback((text: string) => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
return (
<div
style={{
marginTop: "12px",
padding: "12px 16px",
background: "#161b22",
border: "1px solid #238636",
borderRadius: "8px",
fontSize: "0.85em",
}}
>
<div style={{ color: "#3fb950", fontWeight: 600, marginBottom: "8px" }}>
Token generated run the build agent with one of:
</div>
<div style={{ marginBottom: "6px" }}>
<code
style={{
display: "block",
background: "#0d1117",
padding: "8px 10px",
borderRadius: "4px",
color: "#e6edf3",
wordBreak: "break-all",
}}
>
{envCmd}
</code>
</div>
<div>
<code
style={{
display: "block",
background: "#0d1117",
padding: "8px 10px",
borderRadius: "4px",
color: "#e6edf3",
wordBreak: "break-all",
}}
>
{flagCmd}
</code>
</div>
<button
type="button"
onClick={() => copyToClipboard(flagCmd)}
style={{
marginTop: "8px",
fontSize: "0.8em",
padding: "3px 10px",
borderRadius: "4px",
border: "1px solid #444",
background: "none",
color: "#aaa",
cursor: "pointer",
}}
>
{copied ? "Copied!" : "Copy flag command"}
</button>
<div style={{ marginTop: "8px", color: "#666", fontSize: "0.85em" }}>
This token is single-use. Generate a new one for each agent.
</div>
</div>
);
}
function AgentRow({
agent,
projects,
onRemove,
onAssign,
}: {
agent: JoinedAgent;
projects: GatewayProject[];
onRemove: (id: string) => void;
onAssign: (id: string, project: string | null) => void;
}) {
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
const isAssigned = Boolean(agent.assigned_project);
return (
<div
data-testid={`agent-row-${agent.id}`}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 14px",
background: "#161b22",
border: "1px solid #30363d",
borderRadius: "8px",
marginBottom: "8px",
}}
>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: isAssigned ? "#3fb950" : "#6e7681",
flexShrink: 0,
}}
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
{agent.address}
</div>
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
Registered {registeredAt}
</div>
</div>
<select
data-testid={`assign-agent-${agent.id}`}
value={agent.assigned_project ?? ""}
onChange={(e) =>
onAssign(agent.id, e.target.value === "" ? null : e.target.value)
}
style={{
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
cursor: "pointer",
}}
>
<option value=""> unassigned </option>
{projects.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<button
type="button"
data-testid={`remove-agent-${agent.id}`}
onClick={() => onRemove(agent.id)}
style={{
fontSize: "0.8em",
padding: "4px 10px",
borderRadius: "4px",
border: "1px solid #f85149",
background: "none",
color: "#f85149",
cursor: "pointer",
}}
>
Remove
</button>
</div>
);
}
/// Gateway management panel — rendered when running in `--gateway` mode.
export function GatewayPanel() {
const [agents, setAgents] = useState<JoinedAgent[]>([]);
const [projects, setProjects] = useState<GatewayProject[]>([]);
const [token, setToken] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add-project form state
const [newProjectName, setNewProjectName] = useState("");
const [newProjectUrl, setNewProjectUrl] = useState("");
const [addingProject, setAddingProject] = useState(false);
useEffect(() => {
gatewayApi
.listAgents()
.then(setAgents)
.catch(() => setAgents([]));
gatewayApi
.getGatewayInfo()
.then((info) => setProjects(info.projects))
.catch(() => setProjects([]));
}, []);
const handleAddAgent = useCallback(async () => {
setGenerating(true);
setError(null);
setToken(null);
try {
const result = await gatewayApi.generateToken();
setToken(result.token);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setGenerating(false);
}
}, []);
const handleRemoveAgent = useCallback(async (id: string) => {
try {
await gatewayApi.removeAgent(id);
setAgents((prev) => prev.filter((a) => a.id !== id));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
const handleAssignAgent = useCallback(
async (id: string, project: string | null) => {
try {
const updated = await gatewayApi.assignAgent(id, project);
setAgents((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a)),
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
},
[],
);
const handleAddProject = useCallback(async () => {
const name = newProjectName.trim();
const url = newProjectUrl.trim();
if (!name || !url) return;
setAddingProject(true);
setError(null);
try {
const created = await gatewayApi.addProject(name, url);
setProjects((prev) => [...prev, created]);
setNewProjectName("");
setNewProjectUrl("");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setAddingProject(false);
}
}, [newProjectName, newProjectUrl]);
const handleRemoveProject = useCallback(async (name: string) => {
if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) {
return;
}
setError(null);
try {
await gatewayApi.removeProject(name);
setProjects((prev) => prev.filter((p) => p.name !== name));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
return (
<div
style={{
minHeight: "100vh",
background: "#0d1117",
color: "#e6edf3",
padding: "32px",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<div style={{ maxWidth: "720px", margin: "0 auto" }}>
<h1 style={{ fontSize: "1.5em", fontWeight: 700, marginBottom: "4px" }}>
Huskies Gateway
</h1>
<p style={{ color: "#8b949e", marginBottom: "32px" }}>
Manage build agents connected to this gateway.
</p>
{/* Add Agent */}
<section style={{ marginBottom: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Add Agent
</h2>
<button
type="button"
data-testid="add-agent-button"
onClick={handleAddAgent}
disabled={generating}
style={{
padding: "8px 18px",
borderRadius: "6px",
border: "1px solid #238636",
background: generating ? "#1a2f1a" : "#238636",
color: "#fff",
cursor: generating ? "not-allowed" : "pointer",
fontWeight: 600,
fontSize: "0.9em",
}}
>
{generating ? "Generating…" : "Add Agent"}
</button>
{token && <TokenDisplay token={token} />}
</section>
{/* Agent list */}
<section>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Connected Agents{" "}
{agents.length > 0 && (
<span
style={{
fontSize: "0.8em",
color: "#8b949e",
fontWeight: 400,
}}
>
({agents.length})
</span>
)}
</h2>
{agents.length === 0 ? (
<p style={{ color: "#6e7681" }}>
No agents connected yet. Click "Add Agent" to generate a join
token.
</p>
) : (
<div>
{agents.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
projects={projects}
onRemove={handleRemoveAgent}
onAssign={handleAssignAgent}
/>
))}
</div>
)}
</section>
{/* Project management */}
<section style={{ marginTop: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Projects{" "}
{projects.length > 0 && (
<span style={{ fontSize: "0.8em", color: "#8b949e", fontWeight: 400 }}>
({projects.length})
</span>
)}
</h2>
{/* Existing projects list */}
{projects.map((p) => (
<div
key={p.name}
data-testid={`project-row-${p.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 14px",
background: "#161b22",
border: "1px solid #30363d",
borderRadius: "8px",
marginBottom: "8px",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{p.name}</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>{p.url}</div>
</div>
<button
type="button"
data-testid={`remove-project-${p.name}`}
onClick={() => handleRemoveProject(p.name)}
style={{
fontSize: "0.8em",
padding: "4px 10px",
borderRadius: "4px",
border: "1px solid #f85149",
background: "none",
color: "#f85149",
cursor: "pointer",
}}
>
Remove
</button>
</div>
))}
{/* Add project form */}
<div
style={{
marginTop: "12px",
display: "flex",
gap: "8px",
alignItems: "flex-end",
flexWrap: "wrap",
}}
>
<div style={{ flex: "1 1 140px" }}>
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
Name
</div>
<input
data-testid="new-project-name"
type="text"
placeholder="my-project"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
fontSize: "0.85em",
}}
/>
</div>
<div style={{ flex: "2 1 200px" }}>
<div style={{ fontSize: "0.75em", color: "#8b949e", marginBottom: "4px" }}>
Container URL
</div>
<input
data-testid="new-project-url"
type="text"
placeholder="http://localhost:3001"
value={newProjectUrl}
onChange={(e) => setNewProjectUrl(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
fontSize: "0.85em",
}}
/>
</div>
<button
type="button"
data-testid="add-project-button"
onClick={handleAddProject}
disabled={addingProject || !newProjectName.trim() || !newProjectUrl.trim()}
style={{
padding: "6px 14px",
borderRadius: "4px",
border: "1px solid #238636",
background: addingProject ? "#1a2f1a" : "#238636",
color: "#fff",
cursor: addingProject ? "not-allowed" : "pointer",
fontWeight: 600,
fontSize: "0.85em",
whiteSpace: "nowrap",
}}
>
{addingProject ? "Adding…" : "Add Project"}
</button>
</div>
</section>
{error && (
<div
style={{
marginTop: "16px",
padding: "10px 14px",
background: "#f8514911",
border: "1px solid #f85149",
borderRadius: "6px",
color: "#f85149",
fontSize: "0.875em",
}}
>
{error}
</div>
)}
</div>
</div>
);
}