2026-04-14 12:02:17 +00:00
|
|
|
/// 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.
|
2026-04-14 12:25:12 +00:00
|
|
|
/// - A list of connected agents with per-agent project assignment and "Remove" buttons.
|
2026-04-14 12:02:17 +00:00
|
|
|
|
|
|
|
|
import * as React from "react";
|
2026-04-14 12:25:12 +00:00
|
|
|
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
|
2026-04-14 12:02:17 +00:00
|
|
|
|
|
|
|
|
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,
|
2026-04-14 12:25:12 +00:00
|
|
|
projects,
|
2026-04-14 12:02:17 +00:00
|
|
|
onRemove,
|
2026-04-14 12:25:12 +00:00
|
|
|
onAssign,
|
2026-04-14 12:02:17 +00:00
|
|
|
}: {
|
|
|
|
|
agent: JoinedAgent;
|
2026-04-14 12:25:12 +00:00
|
|
|
projects: GatewayProject[];
|
2026-04-14 12:02:17 +00:00
|
|
|
onRemove: (id: string) => void;
|
2026-04-14 12:25:12 +00:00
|
|
|
onAssign: (id: string, project: string | null) => void;
|
2026-04-14 12:02:17 +00:00
|
|
|
}) {
|
|
|
|
|
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
2026-04-14 12:25:12 +00:00
|
|
|
const isAssigned = Boolean(agent.assigned_project);
|
2026-04-14 12:02:17 +00:00
|
|
|
|
|
|
|
|
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%",
|
2026-04-14 12:25:12 +00:00
|
|
|
background: isAssigned ? "#3fb950" : "#6e7681",
|
2026-04-14 12:02:17 +00:00
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
2026-04-14 12:25:12 +00:00
|
|
|
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
|
2026-04-14 12:02:17 +00:00
|
|
|
/>
|
|
|
|
|
<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>
|
2026-04-14 12:25:12 +00:00
|
|
|
<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>
|
2026-04-14 12:02:17 +00:00
|
|
|
<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[]>([]);
|
2026-04-14 12:25:12 +00:00
|
|
|
const [projects, setProjects] = useState<GatewayProject[]>([]);
|
2026-04-14 12:02:17 +00:00
|
|
|
const [token, setToken] = useState<string | null>(null);
|
|
|
|
|
const [generating, setGenerating] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
2026-04-15 18:02:47 +00:00
|
|
|
// Add-project form state
|
|
|
|
|
const [newProjectName, setNewProjectName] = useState("");
|
|
|
|
|
const [newProjectUrl, setNewProjectUrl] = useState("");
|
|
|
|
|
const [addingProject, setAddingProject] = useState(false);
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
gatewayApi
|
|
|
|
|
.listAgents()
|
|
|
|
|
.then(setAgents)
|
|
|
|
|
.catch(() => setAgents([]));
|
2026-04-14 12:25:12 +00:00
|
|
|
gatewayApi
|
|
|
|
|
.getGatewayInfo()
|
|
|
|
|
.then((info) => setProjects(info.projects))
|
|
|
|
|
.catch(() => setProjects([]));
|
2026-04-14 12:02:17 +00:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-14 12:25:12 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-15 18:02:47 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
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}
|
2026-04-14 12:25:12 +00:00
|
|
|
projects={projects}
|
2026-04-14 12:02:17 +00:00
|
|
|
onRemove={handleRemoveAgent}
|
2026-04-14 12:25:12 +00:00
|
|
|
onAssign={handleAssignAgent}
|
2026-04-14 12:02:17 +00:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-04-15 18:02:47 +00:00
|
|
|
{/* 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>
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|