feat: add config profiles

- Add ConfigProfile and ConfigProfileInclude data models with migrations
- Implement profile resolver service with ordered includes and merge rules
- Add profile CRUD API with validation, compatibility, and cycle detection
- Add instance API plumbing for profile selection on create/start/restart
- Add resolved profile preview and default resolution APIs
- Add frontend config profile API client and management UI
- Add launch/restart profile selection UI
- Add backend integration and unit tests (31 passing)

OpenSpec: add-config-profiles
Quality gates: ruff, TypeScript compile, 31 tests passing
This commit is contained in:
2026-05-24 17:58:39 +00:00
parent 4de312c170
commit 9ad11a021c
23 changed files with 3136 additions and 125 deletions
+586
View File
@@ -0,0 +1,586 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createConfigProfile,
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
updateConfigProfile,
type ConfigProfile,
type CreateConfigProfileRequest,
type ResolvedProfile,
} from "../api/config_profiles";
import { Icon } from "../components/icon";
export const ConfigProfilesPage = () => {
const navigate = useNavigate();
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingProfile, setEditingProfile] = useState<ConfigProfile | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
files: {},
is_default: false,
});
const loadProfiles = useCallback(async () => {
try {
setLoading(true);
const data = await listConfigProfiles();
setProfiles(data);
setError(null);
} catch {
setError("Failed to load config profiles");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProfiles();
}, [loadProfiles]);
const resetForm = () => {
setFormData({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
files: {},
is_default: false,
});
setEditingProfile(null);
setShowForm(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name?.trim()) return;
try {
await createConfigProfile(formData);
resetForm();
await loadProfiles();
} catch {
setError("Failed to create config profile");
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProfile || !formData.name?.trim()) return;
try {
await updateConfigProfile(editingProfile.id, formData);
resetForm();
await loadProfiles();
} catch {
setError("Failed to update config profile");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this config profile?")) return;
try {
await deleteConfigProfile(id);
await loadProfiles();
} catch {
setError("Failed to delete config profile");
}
};
const startEdit = (profile: ConfigProfile) => {
setEditingProfile(profile);
setFormData({
name: profile.name,
description: profile.description || undefined,
project_id: profile.project_id || undefined,
tool_type_id: profile.tool_type_id || undefined,
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
files: profile.files,
is_default: profile.is_default,
});
setShowForm(true);
setPreviewData(null);
setPreviewingId(null);
};
const handlePreview = async (id: string) => {
try {
setPreviewingId(id);
const data = await previewConfigProfile(id);
setPreviewData(data);
} catch {
setError("Failed to preview config profile");
} finally {
setPreviewingId(null);
}
};
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
key: K,
value: CreateConfigProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const addEnvVar = () => {
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
};
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
if (oldKey !== newKey) {
delete envVars[oldKey];
}
envVars[newKey] = value;
return { ...prev, env_vars: envVars };
});
};
const removeEnvVar = (key: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
delete envVars[key];
return { ...prev, env_vars: envVars };
});
};
const addFile = () => {
setFormData((prev) => ({
...prev,
files: { ...prev.files, "": "" },
}));
};
const updateFile = (oldPath: string, newPath: string, content: string) => {
setFormData((prev) => {
const files = { ...prev.files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
return { ...prev, files };
});
};
const removeFile = (path: string) => {
setFormData((prev) => {
const files = { ...prev.files };
delete files[path];
return { ...prev, files };
});
};
const addMount = () => {
setFormData((prev) => ({
...prev,
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
}));
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
return { ...prev, mounts };
});
};
const removeMount = (index: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts.splice(index, 1);
return { ...prev, mounts };
});
};
const addMountFile = (mountIndex: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[mountIndex] = {
...mounts[mountIndex],
files: { ...mounts[mountIndex].files, "": "" },
};
return { ...prev, mounts };
});
};
const updateMountFile = (
mountIndex: number,
oldPath: string,
newPath: string,
content: string
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
};
const removeMountFile = (mountIndex: number, path: string) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
delete files[path];
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
};
if (loading) return <div>Loading config profiles...</div>;
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Config Profiles</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
</div>
{error && <div className="error">{error}</div>}
{!showForm && (
<button
className="primary-button"
type="button"
onClick={() => {
resetForm();
setShowForm(true);
}}
>
<Icon name="add" size="sm" />
Create Profile
</button>
)}
{showForm && (
<form onSubmit={editingProfile ? handleUpdate : handleCreate} className="card stack">
<h3>{editingProfile ? "Edit Profile" : "Create Profile"}</h3>
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input
id="profile-name"
type="text"
value={formData.name}
onChange={(e) => updateFormField("name", e.target.value)}
placeholder="e.g., Development Environment"
required
/>
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input
id="profile-description"
type="text"
value={formData.description || ""}
onChange={(e) => updateFormField("description", e.target.value || undefined)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label htmlFor="profile-project">Project ID</label>
<input
id="profile-project"
type="text"
value={formData.project_id || ""}
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
placeholder="Optional project UUID"
/>
</div>
<div className="form-group">
<label htmlFor="profile-tool">Tool Type ID</label>
<input
id="profile-tool"
type="text"
value={formData.tool_type_id || ""}
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
placeholder="Optional tool type UUID"
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => updateFormField("is_default", e.target.checked)}
/>
Set as default for this scope
</label>
</div>
<div className="form-section">
<h4>Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value]) => (
<div key={key} className="form-row">
<input
type="text"
value={key}
onChange={(e) => updateEnvVar(key, e.target.value, value)}
placeholder="VAR_NAME"
/>
<input
type="text"
value={value}
onChange={(e) => updateEnvVar(key, key, e.target.value)}
placeholder="value"
/>
<button type="button" className="danger-button" onClick={() => removeEnvVar(key)}>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addEnvVar}>
<Icon name="add" size="sm" />
Add Variable
</button>
</div>
<div className="form-section">
<h4>Runtime Hints</h4>
<textarea
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateFormField("runtime_hints", parsed);
} catch {
// Invalid JSON, ignore
}
}}
placeholder='{"start_command": "npm start"}'
rows={4}
/>
</div>
<div className="form-section">
<h4>Files</h4>
{Object.entries(formData.files || {}).map(([path, content]) => (
<div key={path} className="file-entry">
<input
type="text"
value={path}
onChange={(e) => updateFile(path, e.target.value, content)}
placeholder="relative/path/to/file"
/>
<textarea
value={content}
onChange={(e) => updateFile(path, path, e.target.value)}
placeholder="File content"
rows={3}
/>
<button type="button" className="danger-button" onClick={() => removeFile(path)}>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addFile}>
<Icon name="add" size="sm" />
Add File
</button>
</div>
<div className="form-section">
<h4>Mounts</h4>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="mount-entry card">
<div className="form-row">
<input
type="text"
value={mount.target}
onChange={(e) => updateMount(index, { target: e.target.value })}
placeholder="/target/path"
/>
<select
value={mount.mode}
onChange={(e) =>
updateMount(index, { mode: e.target.value as "ro" | "rw" })
}
>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button
type="button"
className="danger-button"
onClick={() => removeMount(index)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<div className="mount-files">
{Object.entries(mount.files).map(([path, content]) => (
<div key={path} className="file-entry">
<input
type="text"
value={path}
onChange={(e) =>
updateMountFile(index, path, e.target.value, content)
}
placeholder="relative/path"
/>
<textarea
value={content}
onChange={(e) =>
updateMountFile(index, path, path, e.target.value)
}
placeholder="File content"
rows={2}
/>
<button
type="button"
className="danger-button"
onClick={() => removeMountFile(index, path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button"
onClick={() => addMountFile(index)}
>
<Icon name="add" size="sm" />
Add File
</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={addMount}>
<Icon name="add" size="sm" />
Add Mount
</button>
</div>
<div className="form-actions">
<button type="submit" className="primary-button">
<Icon name="save" size="sm" />
{editingProfile ? "Update Profile" : "Create Profile"}
</button>
<button type="button" className="secondary-button" onClick={resetForm}>
Cancel
</button>
</div>
</form>
)}
{previewData && (
<div className="card stack preview-panel">
<h3>Resolved Profile Preview</h3>
<pre>{JSON.stringify(previewData, null, 2)}</pre>
<button className="secondary-button" onClick={() => setPreviewData(null)}>
Close Preview
</button>
</div>
)}
<div className="profiles-list">
{profiles.length === 0 ? (
<p className="muted">No config profiles yet. Create one above.</p>
) : (
profiles.map((profile) => (
<div key={profile.id} className="profile-card card">
<div className="profile-header">
<div>
<h3>{profile.name}</h3>
{profile.description && <p className="muted">{profile.description}</p>}
<div className="profile-meta">
{profile.project_id && (
<span className="badge">Project: {profile.project_id}</span>
)}
{profile.tool_type_id && (
<span className="badge">Tool: {profile.tool_type_id}</span>
)}
{profile.is_default && <span className="badge badge-primary">Default</span>}
</div>
</div>
<div className="profile-actions">
<button
className="secondary-button"
onClick={() => handlePreview(profile.id)}
disabled={previewingId === profile.id}
>
{previewingId === profile.id ? (
<>
<Icon name="loading" size="sm" />
Previewing...
</>
) : (
<>
<Icon name="info" size="sm" />
Preview
</>
)}
</button>
<button className="secondary-button" onClick={() => startEdit(profile)}>
<Icon name="edit" size="sm" />
Edit
</button>
<button
className="danger-button"
onClick={() => handleDelete(profile.id)}
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</div>
{profile.includes.length > 0 && (
<div className="profile-includes">
<span className="muted">Includes: {profile.includes.length} profile(s)</span>
</div>
)}
<div className="profile-summary">
{Object.keys(profile.env_vars).length > 0 && (
<span>{Object.keys(profile.env_vars).length} env vars</span>
)}
{Object.keys(profile.files).length > 0 && (
<span>{Object.keys(profile.files).length} files</span>
)}
{profile.mounts.length > 0 && (
<span>{profile.mounts.length} mounts</span>
)}
</div>
</div>
))
)}
</div>
</section>
);
};
+2 -1
View File
@@ -9,6 +9,7 @@ type SettingsStatus = "loading" | "ready" | "error";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Config Profiles", path: "config-profiles" },
] as const;
const THEME_OPTIONS = [
@@ -104,7 +105,7 @@ export const SettingsPage = () => {
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences and SSH keys.</p>
<p className="muted">General preferences, SSH keys, and config profiles.</p>
</header>
<nav className="settings-tabs" aria-label="Settings sections">