Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user