feat: instance-level SSH key selection for container mounting
- Revert mistaken ssh_key_id from ConfigProfile (model, API, resolver, frontend)
- Add ssh_key_ids JSON column to tool_instances via migration
- Update create_instance to accept and store ssh_key_ids
- Update start_instance to mount selected SSH keys to {home_dir}/.ssh
- Update list_instances to return ssh_key_ids
- Frontend CreateSessionForm: multi-select SSH key checkboxes
- Frontend instance-list: SSH key selector for start/restart actions
- Maintain separate SSH key dirs per key to avoid conflicts
Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { CreateSessionForm } from "./create-session-form";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { useEventContext } from "../state/events";
|
||||
|
||||
const API_BASE_URL =
|
||||
@@ -47,6 +48,10 @@ export const InstanceList = ({
|
||||
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);
|
||||
@@ -105,8 +110,12 @@ export const InstanceList = ({
|
||||
const loadConfigProfiles = useCallback(
|
||||
async (toolTypeId: string) => {
|
||||
try {
|
||||
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
||||
const [profiles, keys] = await Promise.all([
|
||||
listConfigProfiles(projectId, toolTypeId),
|
||||
listSSHKeys(),
|
||||
]);
|
||||
setConfigProfiles(profiles);
|
||||
setSshKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -114,12 +123,23 @@ export const InstanceList = ({
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
||||
const handleStart = async (
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId, configProfileId);
|
||||
await startInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
@@ -144,12 +164,20 @@ export const InstanceList = ({
|
||||
const handleRestart = async (
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
||||
await restartInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
@@ -279,69 +307,120 @@ export const InstanceList = ({
|
||||
)}
|
||||
{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("");
|
||||
{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"
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
<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" && (
|
||||
@@ -376,68 +455,119 @@ export const InstanceList = ({
|
||||
<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("");
|
||||
{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"
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user