Compare commits
2 Commits
4b09f611d6
...
383a874bcf
| Author | SHA1 | Date | |
|---|---|---|---|
| 383a874bcf | |||
| 18634387c7 |
@@ -15,6 +15,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions"
|
|||||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
|
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Icon } from "../components/icon";
|
||||||
import {
|
import {
|
||||||
createConfigProfile,
|
createConfigProfile,
|
||||||
deleteConfigProfile,
|
deleteConfigProfile,
|
||||||
@@ -10,15 +10,23 @@ import {
|
|||||||
type CreateConfigProfileRequest,
|
type CreateConfigProfileRequest,
|
||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { Icon } from "../components/icon";
|
import { listProjects } from "../api/projects";
|
||||||
|
import type { Project } from "../types";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
|
||||||
|
type Status = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const ConfigProfilesPage = () => {
|
export const ConfigProfilesPage = () => {
|
||||||
const navigate = useNavigate();
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,22 +40,28 @@ export const ConfigProfilesPage = () => {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadProfiles = useCallback(async () => {
|
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const [profs, projs, types] = await Promise.all([
|
||||||
const data = await listConfigProfiles();
|
listConfigProfiles(),
|
||||||
setProfiles(data);
|
listProjects(),
|
||||||
setError(null);
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setProfiles(profs || []);
|
||||||
|
setProjects(projs || []);
|
||||||
|
setToolTypes(types || []);
|
||||||
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load config profiles");
|
setStatus("error");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProfiles();
|
void loadData();
|
||||||
}, [loadProfiles]);
|
}, [loadData]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -59,49 +73,12 @@ export const ConfigProfilesPage = () => {
|
|||||||
files: {},
|
files: {},
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
setEditingProfile(null);
|
setError(null);
|
||||||
setShowForm(false);
|
setSaveStatus("idle");
|
||||||
|
setPreviewData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const populateForm = (profile: ConfigProfile) => {
|
||||||
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({
|
setFormData({
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
description: profile.description || undefined,
|
description: profile.description || undefined,
|
||||||
@@ -113,9 +90,86 @@ export const ConfigProfilesPage = () => {
|
|||||||
files: profile.files,
|
files: profile.files,
|
||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setError(null);
|
||||||
|
setSaveStatus("idle");
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
setPreviewingId(null);
|
};
|
||||||
|
|
||||||
|
const handleSelectProfile = (profile: ConfigProfile | null) => {
|
||||||
|
if (profile) {
|
||||||
|
setSelectedProfileId(profile.id);
|
||||||
|
setIsCreating(false);
|
||||||
|
populateForm(profile);
|
||||||
|
} else {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractErrorMessage = (err: unknown): string => {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
||||||
|
const detail = axiosError?.response?.data?.detail;
|
||||||
|
if (typeof detail === "string") return detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||||
|
}
|
||||||
|
return "Failed to save";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
setSaveStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
const newProfile = await createConfigProfile(formData);
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedProfileId(newProfile.id);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
await loadData();
|
||||||
|
// Re-populate with the new profile data
|
||||||
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
||||||
|
if (refreshed) populateForm(refreshed);
|
||||||
|
} else if (selectedProfile) {
|
||||||
|
await updateConfigProfile(selectedProfile.id, formData);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
await loadData();
|
||||||
|
// Refresh the selected profile data
|
||||||
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
||||||
|
if (refreshed) populateForm(refreshed);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteConfigProfile(id);
|
||||||
|
if (selectedProfileId === id) {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete config profile");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreview = async (id: string) => {
|
const handlePreview = async (id: string) => {
|
||||||
@@ -135,6 +189,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
value: CreateConfigProfileRequest[K]
|
value: CreateConfigProfileRequest[K]
|
||||||
) => {
|
) => {
|
||||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVar = () => {
|
const addEnvVar = () => {
|
||||||
@@ -142,6 +197,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
env_vars: { ...prev.env_vars, "": "" },
|
env_vars: { ...prev.env_vars, "": "" },
|
||||||
}));
|
}));
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||||
@@ -153,6 +209,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
envVars[newKey] = value;
|
envVars[newKey] = value;
|
||||||
return { ...prev, env_vars: envVars };
|
return { ...prev, env_vars: envVars };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEnvVar = (key: string) => {
|
const removeEnvVar = (key: string) => {
|
||||||
@@ -161,6 +218,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
delete envVars[key];
|
delete envVars[key];
|
||||||
return { ...prev, env_vars: envVars };
|
return { ...prev, env_vars: envVars };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addFile = () => {
|
const addFile = () => {
|
||||||
@@ -168,6 +226,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
files: { ...prev.files, "": "" },
|
files: { ...prev.files, "": "" },
|
||||||
}));
|
}));
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||||
@@ -179,6 +238,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
files[newPath] = content;
|
files[newPath] = content;
|
||||||
return { ...prev, files };
|
return { ...prev, files };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFile = (path: string) => {
|
const removeFile = (path: string) => {
|
||||||
@@ -187,6 +247,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
delete files[path];
|
delete files[path];
|
||||||
return { ...prev, files };
|
return { ...prev, files };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMount = () => {
|
const addMount = () => {
|
||||||
@@ -194,6 +255,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||||
}));
|
}));
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||||
@@ -202,6 +264,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts[index] = { ...mounts[index], ...updates };
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
return { ...prev, mounts };
|
return { ...prev, mounts };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMount = (index: number) => {
|
const removeMount = (index: number) => {
|
||||||
@@ -210,6 +273,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts.splice(index, 1);
|
mounts.splice(index, 1);
|
||||||
return { ...prev, mounts };
|
return { ...prev, mounts };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMountFile = (mountIndex: number) => {
|
const addMountFile = (mountIndex: number) => {
|
||||||
@@ -221,6 +285,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
};
|
};
|
||||||
return { ...prev, mounts };
|
return { ...prev, mounts };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMountFile = (
|
const updateMountFile = (
|
||||||
@@ -239,6 +304,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
return { ...prev, mounts };
|
return { ...prev, mounts };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMountFile = (mountIndex: number, path: string) => {
|
const removeMountFile = (mountIndex: number, path: string) => {
|
||||||
@@ -249,42 +315,219 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
return { ...prev, mounts };
|
return { ...prev, mounts };
|
||||||
});
|
});
|
||||||
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div>Loading config profiles...</div>;
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<p>Loading Config Profiles...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<p className="text-error">Failed to load Config Profiles.</p>
|
||||||
|
<button onClick={loadData}>
|
||||||
|
<Icon name="refresh" size="sm" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
||||||
<div className="page-header">
|
{/* Left Sidebar - Profile List */}
|
||||||
<div>
|
<div
|
||||||
<p className="eyebrow">Settings</p>
|
style={{
|
||||||
<h1>Config Profiles</h1>
|
width: "280px",
|
||||||
</div>
|
minWidth: "280px",
|
||||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
borderRight: "1px solid var(--border)",
|
||||||
Back to settings
|
display: "flex",
|
||||||
</button>
|
flexDirection: "column",
|
||||||
</div>
|
background: "var(--panel)",
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
|
||||||
|
|
||||||
{!showForm && (
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
resetForm();
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="add" size="sm" />
|
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
||||||
Create Profile
|
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
|
||||||
|
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
||||||
|
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => handleSelectProfile(profile)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "none",
|
||||||
|
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
|
||||||
|
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedProfileId !== profile.id) {
|
||||||
|
e.currentTarget.style.background = "#ece7df";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedProfileId !== profile.id) {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: "0.9375rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{profile.name}
|
||||||
|
{profile.is_default && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.025em"
|
||||||
|
}}>
|
||||||
|
default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
||||||
|
{profile.project_id && "Project scoped"}
|
||||||
|
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
|
||||||
|
{!profile.project_id && !profile.tool_type_id && "Global"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(profile.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0.25rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
className="delete-btn"
|
||||||
|
title="Delete profile"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--brand)";
|
||||||
|
e.currentTarget.style.color = "var(--brand)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Editor */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||||
|
{!selectedProfileId && !isCreating ? (
|
||||||
|
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||||
|
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||||
|
<Icon name="folder" size="lg" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
|
||||||
|
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||||
|
{isCreating ? "Create Profile" : selectedProfile?.name}
|
||||||
|
</h1>
|
||||||
|
{!isCreating && selectedProfile && (
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
{selectedProfile.project_id && `Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
|
||||||
|
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
|
||||||
|
{selectedProfile.tool_type_id && `Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isCreating && selectedProfile && (
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handlePreview(selectedProfile.id)}
|
||||||
|
disabled={previewingId === selectedProfile.id}
|
||||||
|
>
|
||||||
|
{previewingId === selectedProfile.id ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Previewing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="info" size="sm" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error" style={{ marginBottom: "1rem" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{saveStatus === "saved" && (
|
||||||
<form onSubmit={editingProfile ? handleUpdate : handleCreate} className="card stack">
|
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<h3>{editingProfile ? "Edit Profile" : "Create Profile"}</h3>
|
<Icon name="success" size="sm" />
|
||||||
|
Profile saved successfully
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profile-name">Name *</label>
|
<label htmlFor="profile-name">Name *</label>
|
||||||
<input
|
<input
|
||||||
@@ -293,6 +536,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => updateFormField("name", e.target.value)}
|
onChange={(e) => updateFormField("name", e.target.value)}
|
||||||
placeholder="e.g., Development Environment"
|
placeholder="e.g., Development Environment"
|
||||||
|
className="form-input"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,29 +549,44 @@ export const ConfigProfilesPage = () => {
|
|||||||
value={formData.description || ""}
|
value={formData.description || ""}
|
||||||
onChange={(e) => updateFormField("description", e.target.value || undefined)}
|
onChange={(e) => updateFormField("description", e.target.value || undefined)}
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
<label htmlFor="profile-project">Project ID</label>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<input
|
<label htmlFor="profile-project">Project</label>
|
||||||
|
<select
|
||||||
id="profile-project"
|
id="profile-project"
|
||||||
type="text"
|
|
||||||
value={formData.project_id || ""}
|
value={formData.project_id || ""}
|
||||||
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
|
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
|
||||||
placeholder="Optional project UUID"
|
className="form-input"
|
||||||
/>
|
>
|
||||||
|
<option value="">None (Global)</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label htmlFor="profile-tool">Tool Type ID</label>
|
<label htmlFor="profile-tool">Tool Type</label>
|
||||||
<input
|
<select
|
||||||
id="profile-tool"
|
id="profile-tool"
|
||||||
type="text"
|
|
||||||
value={formData.tool_type_id || ""}
|
value={formData.tool_type_id || ""}
|
||||||
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
|
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
|
||||||
placeholder="Optional tool type UUID"
|
className="form-input"
|
||||||
/>
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{toolTypes.map((toolType) => (
|
||||||
|
<option key={toolType.id} value={toolType.id}>
|
||||||
|
{toolType.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -342,22 +601,30 @@ export const ConfigProfilesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h4>Environment Variables</h4>
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||||
<div key={idx} className="form-row">
|
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => updateEnvVar(key, e.target.value, value)}
|
onChange={(e) => updateEnvVar(key, e.target.value, value)}
|
||||||
placeholder="VAR_NAME"
|
placeholder="VAR_NAME"
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => updateEnvVar(key, key, e.target.value)}
|
onChange={(e) => updateEnvVar(key, key, e.target.value)}
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="danger-button" onClick={() => removeEnvVar(key)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => removeEnvVar(key)}
|
||||||
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="delete" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +636,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h4>Runtime Hints</h4>
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
|
||||||
<textarea
|
<textarea
|
||||||
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
|
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -382,29 +649,43 @@ export const ConfigProfilesPage = () => {
|
|||||||
}}
|
}}
|
||||||
placeholder='{"start_command": "npm start"}'
|
placeholder='{"start_command": "npm start"}'
|
||||||
rows={4}
|
rows={4}
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h4>Files</h4>
|
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
|
||||||
<p className="muted">Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
|
||||||
|
Relative paths written to the instance directory. Use Mounts below for absolute container paths.
|
||||||
|
</p>
|
||||||
{Object.entries(formData.files || {}).map(([path, content], idx) => (
|
{Object.entries(formData.files || {}).map(([path, content], idx) => (
|
||||||
<div key={idx} className="file-entry">
|
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={path}
|
value={path}
|
||||||
onChange={(e) => updateFile(path, e.target.value, content)}
|
onChange={(e) => updateFile(path, e.target.value, content)}
|
||||||
placeholder="relative/path/to/file"
|
placeholder="relative/path/to/file"
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => removeFile(path)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => updateFile(path, path, e.target.value)}
|
onChange={(e) => updateFile(path, path, e.target.value)}
|
||||||
placeholder="File content"
|
placeholder="File content"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="danger-button" onClick={() => removeFile(path)}>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="secondary-button" onClick={addFile}>
|
<button type="button" className="secondary-button" onClick={addFile}>
|
||||||
@@ -414,38 +695,44 @@ export const ConfigProfilesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h4>Mounts</h4>
|
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
|
||||||
<p className="muted">Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
|
||||||
|
Bind directories into the container at absolute paths. Files are relative to the mount target.
|
||||||
|
</p>
|
||||||
{(formData.mounts || []).map((mount, index) => (
|
{(formData.mounts || []).map((mount, index) => (
|
||||||
<div key={index} className="mount-entry card">
|
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
|
||||||
<div className="form-row">
|
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={mount.target}
|
value={mount.target}
|
||||||
onChange={(e) => updateMount(index, { target: e.target.value })}
|
onChange={(e) => updateMount(index, { target: e.target.value })}
|
||||||
placeholder="/target/path"
|
placeholder="/target/path"
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={mount.mode}
|
value={mount.mode}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMount(index, { mode: e.target.value as "ro" | "rw" })
|
updateMount(index, { mode: e.target.value as "ro" | "rw" })
|
||||||
}
|
}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "120px" }}
|
||||||
>
|
>
|
||||||
<option value="rw">Read/Write</option>
|
<option value="rw">Read/Write</option>
|
||||||
<option value="ro">Read-Only</option>
|
<option value="ro">Read-Only</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger-button"
|
className="ghost-button small"
|
||||||
onClick={() => removeMount(index)}
|
onClick={() => removeMount(index)}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="delete" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mount-files">
|
<div style={{ marginLeft: "1rem" }}>
|
||||||
{Object.entries(mount.files).map(([path, content], idx) => (
|
{Object.entries(mount.files).map(([path, content], idx) => (
|
||||||
<div key={idx} className="file-entry">
|
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={path}
|
value={path}
|
||||||
@@ -453,6 +740,8 @@ export const ConfigProfilesPage = () => {
|
|||||||
updateMountFile(index, path, e.target.value, content)
|
updateMountFile(index, path, e.target.value, content)
|
||||||
}
|
}
|
||||||
placeholder="relative/path"
|
placeholder="relative/path"
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
@@ -461,10 +750,12 @@ export const ConfigProfilesPage = () => {
|
|||||||
}
|
}
|
||||||
placeholder="File content"
|
placeholder="File content"
|
||||||
rows={2}
|
rows={2}
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger-button"
|
className="ghost-button small"
|
||||||
onClick={() => removeMountFile(index, path)}
|
onClick={() => removeMountFile(index, path)}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="delete" size="sm" />
|
||||||
@@ -473,11 +764,12 @@ export const ConfigProfilesPage = () => {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button"
|
className="secondary-button small"
|
||||||
onClick={() => addMountFile(index)}
|
onClick={() => addMountFile(index)}
|
||||||
|
style={{ fontSize: "0.875rem" }}
|
||||||
>
|
>
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="add" size="sm" />
|
||||||
Add File
|
Add File to Mount
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,101 +780,43 @@ export const ConfigProfilesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||||
<button type="submit" className="primary-button">
|
<button type="submit" disabled={saveStatus === "saving"}>
|
||||||
<Icon name="save" size="sm" />
|
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||||
{editingProfile ? "Update Profile" : "Create Profile"}
|
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="secondary-button" onClick={resetForm}>
|
{(isCreating || saveStatus !== "idle") && (
|
||||||
Cancel
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isCreating) {
|
||||||
|
resetForm();
|
||||||
|
} else if (selectedProfile) {
|
||||||
|
populateForm(selectedProfile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="button-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" /> Discard
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
|
|
||||||
{previewData && (
|
{previewData && (
|
||||||
<div className="card stack preview-panel">
|
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
|
||||||
<h3>Resolved Profile Preview</h3>
|
<h3>Resolved Profile Preview</h3>
|
||||||
<pre>{JSON.stringify(previewData, null, 2)}</pre>
|
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>
|
||||||
|
{JSON.stringify(previewData, null, 2)}
|
||||||
|
</pre>
|
||||||
<button className="secondary-button" onClick={() => setPreviewData(null)}>
|
<button className="secondary-button" onClick={() => setPreviewData(null)}>
|
||||||
Close Preview
|
Close Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
<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>
|
</div>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
{ label: "Config Profiles", path: "config-profiles" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export const AppRouter = () => {
|
|||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />}>
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="general" replace />} />
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user