feat: container monitoring frontend UI (PR-2)

- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps)
- useEvents() SSE hook with exponential backoff reconnect
- EventProvider context for app-wide SSE stream sharing
- Event-to-toast bridge with severity mapping and deduplication
- Real-time status badge updates replacing 30s polling
- EventSource auth probe (401/429 detection via fetch)
- 14 frontend tests (useEvents + toast-rules)

Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-28 23:43:00 +02:00
parent 4a7f24348c
commit f13a63dc2f
16 changed files with 1979 additions and 1368 deletions
+443 -430
View File
@@ -3,463 +3,476 @@ import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
checkInstanceHealth,
deleteInstance,
listInstances,
recreateInstanceTunnel,
restartInstance,
startInstance,
stopInstance,
deleteInstance,
listInstances,
restartInstance,
startInstance,
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { useEventContext } from "../state/events";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[];
projectId: string;
repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[];
}
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
export const InstanceList = ({
projectId,
repoId,
projectName,
repoName,
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
// Config profile selection for start/restart
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<
string | null
>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
// Config profile selection for start/restart
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const loadInstances = useCallback(async () => {
setLoading(true);
try {
const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
const loadInstances = useCallback(async () => {
setLoading(true);
try {
const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
const { events } = useEventContext();
useEffect(() => {
void loadInstances();
}, [loadInstances]);
useEffect(() => {
void loadInstances();
}, [loadInstances]);
// Health check polling
useEffect(() => {
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
if (runningInstances.length === 0) return;
// Lightweight list refresh every 60 seconds for resilience
useEffect(() => {
const interval = setInterval(() => void loadInstances(), 60000);
return () => clearInterval(interval);
}, [loadInstances]);
const checkHealth = async () => {
for (const instance of runningInstances) {
try {
const health = await checkInstanceHealth(projectId, repoId, instance.id);
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
}));
} catch {
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() }
}));
}
}
};
// Real-time status updates from SSE events
useEffect(() => {
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
const statusEvents = [
"instance.started",
"instance.health_changed",
"instance.error",
"instance.stopped",
"instance.restarted",
];
if (!statusEvents.includes(latestEvent.event)) return;
// Check immediately
void checkHealth();
// Then every 30 seconds
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [instances, projectId, repoId]);
setInstances((prev) =>
prev.map((inst) =>
inst.id === latestEvent.instance_id
? { ...inst, status: latestEvent.status ?? inst.status }
: inst,
),
);
}, [events]);
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
};
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
};
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
try {
const profiles = await listConfigProfiles(projectId, toolTypeId);
setConfigProfiles(profiles);
} catch {
// ignore
}
}, [projectId]);
const loadConfigProfiles = useCallback(
async (toolTypeId: string) => {
try {
const profiles = await listConfigProfiles(projectId, toolTypeId);
setConfigProfiles(profiles);
} catch {
// ignore
}
},
[projectId],
);
const handleStart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRestart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRestart = async (
instanceId: string,
configProfileId?: string,
) => {
setBusyInstanceId(instanceId);
try {
await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRecreateTunnel = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
} finally {
setBusyInstanceId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
case "starting":
case "probing":
return "var(--info)";
case "unhealthy":
return "var(--warning)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
return (
<div className="instance-list">
<div className="instance-list-header">
<h3>Tool Instances</h3>
<button
className="secondary-button small"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Launch Tool
</button>
</div>
const isTunnelUnhealthy = (instance: ToolInstance) => {
if (instance.status !== "running") return false;
if (!instance.url?.startsWith("http")) return false;
const health = healthStatus[instance.id];
if (!health) return false;
return !health.healthy;
};
{error && <div className="error-message">{error}</div>}
return (
<div className="instance-list">
<div className="instance-list-header">
<h3>Tool Instances</h3>
<button
className="secondary-button small"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Launch Tool
</button>
</div>
{loading ? (
<p className="muted">Loading instances...</p>
) : instances.length === 0 ? (
<p className="muted">No instances yet. Launch a tool to get started.</p>
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
<span
className="status-dot"
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
</div>
{instance.selected_config_profile_id && (
<div className="instance-profile">
<span className="badge">
Profile:{" "}
{configProfiles.find(
(p) => p.id === instance.selected_config_profile_id,
)?.name || instance.selected_config_profile_id}
</span>
</div>
)}
</div>
<div className="instance-actions">
{instance.status === "running" &&
instance.url &&
instance.tool_type_interfaces.includes("web") && (
<>
<a
href={
instance.url.startsWith("http")
? instance.url
: `${API_BASE_URL}${instance.url}`
}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
</>
)}
{instance.status === "running" &&
instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() =>
navigate(`/instances/${instance.id}/terminal`)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
disabled={busyInstanceId === instance.id}
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{error && (
<div className="error-message">{error}</div>
)}
{loading ? (
<p className="muted">Loading instances...</p>
) : instances.length === 0 ? (
<p className="muted">No instances yet. Launch a tool to get started.</p>
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
<span
className="status-dot"
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span className="error-badge" title="Tunnel unreachable">
<Icon name="warning" size="sm" />
tunnel error
</span>
)}
</div>
{instance.selected_config_profile_id && (
<div className="instance-profile">
<span className="badge">
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
</span>
</div>
)}
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
<>
<a
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) => setSelectedProfileForAction(e.target.value)}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
disabled={busyInstanceId === instance.id}
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) => setSelectedProfileForAction(e.target.value)}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<CreateSessionForm
projects={[]}
repositories={[]}
toolTypes={toolTypes}
fixedProjectId={projectId}
fixedRepoId={repoId}
projectName={projectName}
repoName={repoName}
onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
</div>
</div>
)}
</div>
);
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<CreateSessionForm
projects={[]}
repositories={[]}
toolTypes={toolTypes}
fixedProjectId={projectId}
fixedRepoId={repoId}
projectName={projectName}
repoName={repoName}
onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
</div>
</div>
)}
</div>
);
};