415aecc0dd
- Add dynamic viewport bounds and scrollable body regions to dialogs and modals\n- Make tool-launch popups use the shared scrollable body pattern\n- Keep mobile sheets, action sheets, and notification popups scroll-contained\n- Restore the ProjectsPage test setup required for the frontend suite\n\nOpenSpec: fix-dialog-scroll-containment\nQuality gates: npm run typecheck, npm run lint, npm test (88 passed), npm run build
604 lines
17 KiB
TypeScript
604 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Icon } from "../../icon";
|
|
import { LoadingOverlay } from "../../loading-overlay";
|
|
import type { ToolInstance } from "../../../api/sessions";
|
|
import {
|
|
deleteInstance,
|
|
listInstances,
|
|
restartInstance,
|
|
startInstance,
|
|
stopInstance,
|
|
} from "../../../api/sessions";
|
|
import type { ToolType } from "../../../api/tool-types";
|
|
import { CreateSessionForm } from "../session/create-session-form";
|
|
import {
|
|
listConfigProfiles,
|
|
type ConfigProfile,
|
|
} from "../../../api/config-profiles";
|
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
|
import { useEventContext } from "../../../state/events";
|
|
import { useSessions } from "../../../state/sessions";
|
|
|
|
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[];
|
|
}
|
|
|
|
export const InstanceList = ({
|
|
projectId,
|
|
repoId,
|
|
projectName,
|
|
repoName,
|
|
toolTypes,
|
|
}: InstanceListProps) => {
|
|
const navigate = useNavigate();
|
|
const { refreshSessions } = useSessions();
|
|
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);
|
|
|
|
// Config profile selection for start/restart
|
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
|
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
|
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
|
|
string[]
|
|
>([]);
|
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
|
|
|
// Per-instance busy state for actions
|
|
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
|
const [busyLabel, setBusyLabel] = useState("Working...");
|
|
|
|
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]);
|
|
|
|
// Lightweight list refresh every 60 seconds for resilience
|
|
useEffect(() => {
|
|
const interval = setInterval(() => void loadInstances(), 60000);
|
|
return () => clearInterval(interval);
|
|
}, [loadInstances]);
|
|
|
|
// 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;
|
|
|
|
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();
|
|
await refreshSessions();
|
|
};
|
|
|
|
const loadConfigProfiles = useCallback(
|
|
async (toolTypeId: string) => {
|
|
try {
|
|
const [profiles, keys] = await Promise.all([
|
|
listConfigProfiles(projectId, toolTypeId),
|
|
listSSHKeys(),
|
|
]);
|
|
setConfigProfiles(profiles);
|
|
setSshKeys(keys);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
[projectId],
|
|
);
|
|
|
|
const handleStart = async (
|
|
instanceId: string,
|
|
configProfileId?: string,
|
|
sshKeyIds?: string[],
|
|
) => {
|
|
setBusyInstanceId(instanceId);
|
|
setBusyLabel("Starting...");
|
|
try {
|
|
await startInstance(
|
|
projectId,
|
|
repoId,
|
|
instanceId,
|
|
configProfileId,
|
|
sshKeyIds,
|
|
);
|
|
setProfileSelectInstanceId(null);
|
|
setSelectedProfileForAction("");
|
|
setSelectedSshKeyIdsForAction([]);
|
|
await loadInstances();
|
|
} catch {
|
|
setError("Failed to start instance");
|
|
} finally {
|
|
setBusyInstanceId(null);
|
|
}
|
|
};
|
|
|
|
const handleStop = async (instanceId: string) => {
|
|
setBusyInstanceId(instanceId);
|
|
setBusyLabel("Stopping...");
|
|
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,
|
|
sshKeyIds?: string[],
|
|
) => {
|
|
setBusyInstanceId(instanceId);
|
|
setBusyLabel("Restarting...");
|
|
try {
|
|
await restartInstance(
|
|
projectId,
|
|
repoId,
|
|
instanceId,
|
|
configProfileId,
|
|
sshKeyIds,
|
|
);
|
|
setProfileSelectInstanceId(null);
|
|
setSelectedProfileForAction("");
|
|
setSelectedSshKeyIdsForAction([]);
|
|
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);
|
|
setBusyLabel("Deleting...");
|
|
try {
|
|
await deleteInstance(projectId, repoId, instanceId);
|
|
// Update state immediately instead of reloading
|
|
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
|
await refreshSessions();
|
|
} catch {
|
|
setError("Failed to delete instance");
|
|
} 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)";
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
{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="card card-md instance-card">
|
|
<LoadingOverlay
|
|
visible={busyInstanceId === instance.id}
|
|
label={busyLabel}
|
|
/>
|
|
<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={`instance-${instance.id}`}
|
|
rel="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>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.25rem",
|
|
marginTop: "0.25rem",
|
|
}}
|
|
>
|
|
{sshKeys.map((key) => (
|
|
<label
|
|
key={key.id}
|
|
className="checkbox-label checkbox-label-compact"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSshKeyIdsForAction.includes(
|
|
key.id,
|
|
)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedSshKeyIdsForAction((prev) => [
|
|
...prev,
|
|
key.id,
|
|
]);
|
|
} else {
|
|
setSelectedSshKeyIdsForAction((prev) =>
|
|
prev.filter((id) => id !== key.id),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
{key.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="primary-button small"
|
|
onClick={() =>
|
|
void handleStart(
|
|
instance.id,
|
|
selectedProfileForAction || undefined,
|
|
selectedSshKeyIdsForAction.length > 0
|
|
? selectedSshKeyIdsForAction
|
|
: undefined,
|
|
)
|
|
}
|
|
type="button"
|
|
disabled={busyInstanceId === instance.id}
|
|
>
|
|
<Icon name="play" size="sm" />
|
|
Start
|
|
</button>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={() => {
|
|
setProfileSelectInstanceId(null);
|
|
setSelectedProfileForAction("");
|
|
setSelectedSshKeyIdsForAction([]);
|
|
}}
|
|
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 || "",
|
|
);
|
|
setSelectedSshKeyIdsForAction(
|
|
instance.ssh_key_ids || [],
|
|
);
|
|
}}
|
|
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>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.25rem",
|
|
marginTop: "0.25rem",
|
|
}}
|
|
>
|
|
{sshKeys.map((key) => (
|
|
<label
|
|
key={key.id}
|
|
className="checkbox-label checkbox-label-compact"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSshKeyIdsForAction.includes(
|
|
key.id,
|
|
)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedSshKeyIdsForAction((prev) => [
|
|
...prev,
|
|
key.id,
|
|
]);
|
|
} else {
|
|
setSelectedSshKeyIdsForAction((prev) =>
|
|
prev.filter((id) => id !== key.id),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
{key.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="primary-button small"
|
|
onClick={() =>
|
|
void handleRestart(
|
|
instance.id,
|
|
selectedProfileForAction || undefined,
|
|
selectedSshKeyIdsForAction.length > 0
|
|
? selectedSshKeyIdsForAction
|
|
: undefined,
|
|
)
|
|
}
|
|
type="button"
|
|
disabled={busyInstanceId === instance.id}
|
|
>
|
|
<Icon name="refresh" size="sm" />
|
|
Restart
|
|
</button>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={() => {
|
|
setProfileSelectInstanceId(null);
|
|
setSelectedProfileForAction("");
|
|
setSelectedSshKeyIdsForAction([]);
|
|
}}
|
|
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 || "",
|
|
);
|
|
setSelectedSshKeyIdsForAction(
|
|
instance.ssh_key_ids || [],
|
|
);
|
|
}}
|
|
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">
|
|
<div className="dialog-header">
|
|
<h2>Launch Tool</h2>
|
|
</div>
|
|
<div className="dialog-body">
|
|
<CreateSessionForm
|
|
projects={[]}
|
|
repositories={[]}
|
|
toolTypes={toolTypes}
|
|
fixedProjectId={projectId}
|
|
fixedRepoId={repoId}
|
|
projectName={projectName}
|
|
repoName={repoName}
|
|
onSuccess={handleCreateSuccess}
|
|
onCancel={() => setShowCreate(false)}
|
|
submitLabel="Launch"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|