Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Fusion
2026-05-24 19:59:49 +02:00
23 changed files with 3136 additions and 125 deletions
+145
View File
@@ -0,0 +1,145 @@
import { apiClient } from "./client";
export interface ConfigProfile {
id: string;
user_id: string;
name: string;
description: string | null;
project_id: string | null;
tool_type_id: string | null;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
}
export interface ConfigProfileMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
order_index: number;
}
export interface ResolvedProfile {
profile_id: string;
profile_name: string;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
files: Record<string, string>;
mounts: Record<string, string>;
};
included_profiles: Array<{ id: string; name: string }>;
}
export interface ResolvedMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
overridden_files: Record<string, string>;
}
export interface CreateConfigProfileRequest {
name: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateConfigProfileRequest {
name?: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateIncludesRequest {
includes: string[];
}
export const listConfigProfiles = async (
projectId?: string,
toolTypeId?: string
): Promise<ConfigProfile[]> => {
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data;
};
export const createConfigProfile = async (
data: CreateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
return response.data;
};
export const updateConfigProfile = async (
id: string,
data: UpdateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
return response.data;
};
export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`);
};
export const updateProfileIncludes = async (
id: string,
data: UpdateIncludesRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`,
data
);
return response.data;
};
export const previewConfigProfile = async (
id: string
): Promise<ResolvedProfile> => {
const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview`
);
return response.data;
};
export const resolveDefaultProfile = async (
projectId: string,
toolTypeId: string
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
+12 -5
View File
@@ -10,6 +10,7 @@ export interface ToolInstance {
status: string;
url: string | null;
port: number | null;
selected_config_profile_id: string | null;
created_at: string;
}
@@ -49,7 +50,8 @@ export async function createInstance(
displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string
newBranch?: string,
configProfileId?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
@@ -59,6 +61,7 @@ export async function createInstance(
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
config_profile_id: configProfileId,
}
);
return response.data;
@@ -67,10 +70,12 @@ export async function createInstance(
export async function startInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
configProfileId?: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId }
);
return response.data;
}
@@ -89,10 +94,12 @@ export async function stopInstance(
export async function restartInstance(
projectId: string,
repoId: string,
instanceId: string
instanceId: string,
configProfileId?: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId }
);
return response.data;
}
@@ -5,6 +5,7 @@ import type { Project } from "../types";
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
import type { ToolType } from "../api/tool_types";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
interface CreateSessionFormProps {
projects: Project[];
@@ -46,6 +47,8 @@ export const CreateSessionForm = ({
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
@@ -71,6 +74,30 @@ export const CreateSessionForm = ({
void loadKeys();
}, [showCloneMode]);
// Load config profiles when tool type is selected
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedToolType || !projectId) {
setConfigProfiles([]);
setSelectedConfigProfile("");
return;
}
const loadProfiles = async () => {
try {
const profiles = await listConfigProfiles(projectId, selectedToolType);
setConfigProfiles(profiles);
// Auto-select default if available
const defaultProfile = profiles.find((p) => p.is_default);
if (defaultProfile) {
setSelectedConfigProfile(defaultProfile.id);
}
} catch {
// ignore
}
};
void loadProfiles();
}, [selectedToolType, selectedProject, fixedProjectId]);
// Load branches when selected repo changes
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
@@ -138,7 +165,8 @@ export const CreateSessionForm = ({
: undefined,
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined
: undefined,
selectedConfigProfile || undefined
);
setProgress("Starting container...");
@@ -298,8 +326,26 @@ export const CreateSessionForm = ({
</label>
)}
{/* Step 4: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
{/* Step 4: Config Profile */}
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
<label className="form-field">
<select
value={selectedConfigProfile}
onChange={(e) => setSelectedConfigProfile(e.target.value)}
disabled={!hasToolType || isSubmitting}
>
<option value="">No profile (use tool defaults)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
)}
{/* Step 5: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
<div className="form-row">
<label className="form-field">
<div className="radio-group">
@@ -422,8 +468,8 @@ export const CreateSessionForm = ({
</div>
)}
{/* Step 5: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
{/* Step 6: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
<label className="form-field">
<input
type="text"
+131 -21
View File
@@ -13,6 +13,7 @@ import {
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -30,13 +31,18 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
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);
// 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("");
const loadInstances = useCallback(async () => {
setLoading(true);
try {
@@ -88,9 +94,20 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
await loadInstances();
};
const handleStart = async (instanceId: string) => {
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
try {
await startInstance(projectId, repoId, instanceId);
const profiles = await listConfigProfiles(projectId, toolTypeId);
setConfigProfiles(profiles);
} catch {
// ignore
}
}, [projectId]);
const handleStart = async (instanceId: string, configProfileId?: string) => {
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to start instance");
@@ -107,9 +124,11 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
}
};
const handleRestart = async (instanceId: string) => {
const handleRestart = async (instanceId: string, configProfileId?: string) => {
try {
await restartInstance(projectId, repoId, instanceId);
await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances();
} catch {
setError("Failed to restart instance");
@@ -199,6 +218,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
</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") && (
@@ -236,14 +262,57 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</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 handleStart(instance.id, selectedProfileForAction || undefined)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
>
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"
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)}
{instance.status === "running" && (
<>
@@ -274,13 +343,54 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" 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"
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
>
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"
>
<Icon name="refresh" size="sm" />
</button>
)}
</>
)}
<button
+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">
+2
View File
@@ -14,6 +14,7 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ConfigProfilesPage } from "./pages/config-profiles";
import { SessionsPage } from "./pages/sessions";
export const AppRouter = () => {
@@ -40,6 +41,7 @@ export const AppRouter = () => {
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="config-profiles" element={<ConfigProfilesPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="sessions" element={<SessionsPage />} />