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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user