feat: redesign tool workshop with split-pane layout
- Replace tabbed interface with split-pane layout - Left sidebar: scrollable tool type list with selection and create button - Right panel: editable tool type details with tabs for configs and folders - Add dirty state tracking with unsaved changes warning - Improve mobile responsiveness Quality gates: npm run build passed
This commit is contained in:
@@ -28,18 +28,21 @@ import {
|
||||
type UpdateConfigFolderRequest,
|
||||
} from "../api/config_folders";
|
||||
|
||||
type Tab = "types" | "configs" | "folders";
|
||||
type RightPanelTab = "details" | "configs" | "folders";
|
||||
type Status = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolWorkshopPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("types");
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
||||
|
||||
// Tool Type state
|
||||
const [selectedToolType, setSelectedToolType] = useState<ToolType | null>(null);
|
||||
// Selection state
|
||||
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("details");
|
||||
|
||||
// Tool Type form state
|
||||
const [toolTypeForm, setToolTypeForm] = useState({
|
||||
name: "",
|
||||
display_name: "",
|
||||
@@ -56,12 +59,10 @@ export const ToolWorkshopPage = () => {
|
||||
required_variables: "",
|
||||
});
|
||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||
const [showToolTypeForm, setShowToolTypeForm] = useState(false);
|
||||
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||
|
||||
// Config state
|
||||
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
||||
// Config form state
|
||||
const [configForm, setConfigForm] = useState({
|
||||
tool_type_id: "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
@@ -74,9 +75,9 @@ export const ToolWorkshopPage = () => {
|
||||
});
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [showConfigForm, setShowConfigForm] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
||||
|
||||
// Folder state
|
||||
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(null);
|
||||
// Folder form state
|
||||
const [folderForm, setFolderForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -86,6 +87,11 @@ export const ToolWorkshopPage = () => {
|
||||
});
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(null);
|
||||
|
||||
const selectedToolType = toolTypes.find((t) => t.id === selectedToolTypeId) || null;
|
||||
const toolConfigs = configs.filter((c) => c.tool_type_id === selectedToolTypeId);
|
||||
const toolFolders = folders; // Config folders are global, not per-tool-type in current API
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -108,8 +114,7 @@ export const ToolWorkshopPage = () => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// Tool Type handlers
|
||||
const openCreateToolType = () => {
|
||||
const resetToolTypeForm = () => {
|
||||
setToolTypeForm({
|
||||
name: "",
|
||||
display_name: "",
|
||||
@@ -126,11 +131,10 @@ export const ToolWorkshopPage = () => {
|
||||
required_variables: "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(null);
|
||||
setToolTypeDirty(false);
|
||||
};
|
||||
|
||||
const openEditToolType = (toolType: ToolType) => {
|
||||
const populateToolTypeForm = (toolType: ToolType) => {
|
||||
setToolTypeForm({
|
||||
name: toolType.name,
|
||||
display_name: toolType.display_name,
|
||||
@@ -147,8 +151,39 @@ export const ToolWorkshopPage = () => {
|
||||
required_variables: toolType.required_variables?.join(", ") || "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(toolType);
|
||||
setToolTypeDirty(false);
|
||||
};
|
||||
|
||||
const handleSelectToolType = (toolType: ToolType | null) => {
|
||||
if (toolTypeDirty) {
|
||||
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (toolType) {
|
||||
setSelectedToolTypeId(toolType.id);
|
||||
setIsCreating(false);
|
||||
populateToolTypeForm(toolType);
|
||||
} else {
|
||||
setSelectedToolTypeId(null);
|
||||
}
|
||||
setRightPanelTab("details");
|
||||
setShowConfigForm(false);
|
||||
setShowFolderForm(false);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
if (toolTypeDirty) {
|
||||
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedToolTypeId(null);
|
||||
setIsCreating(true);
|
||||
resetToolTypeForm();
|
||||
setRightPanelTab("details");
|
||||
setShowConfigForm(false);
|
||||
setShowFolderForm(false);
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||
@@ -188,21 +223,7 @@ export const ToolWorkshopPage = () => {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (selectedToolType) {
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
} else {
|
||||
if (isCreating) {
|
||||
const input: CreateToolTypeRequest = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
@@ -216,10 +237,26 @@ export const ToolWorkshopPage = () => {
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await createToolType(input);
|
||||
const newTool = await createToolType(input);
|
||||
setIsCreating(false);
|
||||
setSelectedToolTypeId(newTool.id);
|
||||
setToolTypeDirty(false);
|
||||
} else if (selectedToolType) {
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
setToolTypeDirty(false);
|
||||
}
|
||||
setShowToolTypeForm(false);
|
||||
setSelectedToolType(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
@@ -231,6 +268,11 @@ export const ToolWorkshopPage = () => {
|
||||
if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return;
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
if (selectedToolTypeId === id) {
|
||||
setSelectedToolTypeId(null);
|
||||
setIsCreating(false);
|
||||
resetToolTypeForm();
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
@@ -238,9 +280,8 @@ export const ToolWorkshopPage = () => {
|
||||
};
|
||||
|
||||
// Config handlers
|
||||
const openCreateConfig = () => {
|
||||
const resetConfigForm = () => {
|
||||
setConfigForm({
|
||||
tool_type_id: toolTypes[0]?.id || "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
@@ -252,13 +293,16 @@ export const ToolWorkshopPage = () => {
|
||||
volumes_json: "[]",
|
||||
});
|
||||
setConfigError(null);
|
||||
setShowConfigForm(true);
|
||||
setSelectedConfig(null);
|
||||
};
|
||||
|
||||
const openCreateConfig = () => {
|
||||
resetConfigForm();
|
||||
setShowConfigForm(true);
|
||||
};
|
||||
|
||||
const openEditConfig = (config: ToolConfig) => {
|
||||
setConfigForm({
|
||||
tool_type_id: config.tool_type_id,
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
config_type: config.config_type,
|
||||
@@ -278,12 +322,11 @@ export const ToolWorkshopPage = () => {
|
||||
e.preventDefault();
|
||||
setConfigError(null);
|
||||
|
||||
if (!configForm.tool_type_id || !configForm.key.trim()) {
|
||||
if (!selectedToolTypeId || !configForm.key.trim()) {
|
||||
setConfigError("Tool type and key are required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON fields
|
||||
let envVars: Record<string, string> | undefined;
|
||||
let volumes: Array<{ source: string; target: string; type?: string }> | undefined;
|
||||
|
||||
@@ -306,7 +349,7 @@ export const ToolWorkshopPage = () => {
|
||||
}
|
||||
|
||||
const data: CreateToolConfigRequest = {
|
||||
tool_type_id: configForm.tool_type_id,
|
||||
tool_type_id: selectedToolTypeId,
|
||||
key: configForm.key.trim(),
|
||||
value: configForm.value,
|
||||
config_type: configForm.config_type,
|
||||
@@ -326,6 +369,7 @@ export const ToolWorkshopPage = () => {
|
||||
}
|
||||
setShowConfigForm(false);
|
||||
setSelectedConfig(null);
|
||||
resetConfigForm();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
@@ -344,7 +388,7 @@ export const ToolWorkshopPage = () => {
|
||||
};
|
||||
|
||||
// Folder handlers
|
||||
const openCreateFolder = () => {
|
||||
const resetFolderForm = () => {
|
||||
setFolderForm({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -353,10 +397,14 @@ export const ToolWorkshopPage = () => {
|
||||
is_active: true,
|
||||
});
|
||||
setFolderError(null);
|
||||
setShowFolderForm(true);
|
||||
setSelectedFolder(null);
|
||||
};
|
||||
|
||||
const openCreateFolder = () => {
|
||||
resetFolderForm();
|
||||
setShowFolderForm(true);
|
||||
};
|
||||
|
||||
const openEditFolder = (folder: ConfigFolder) => {
|
||||
setFolderForm({
|
||||
name: folder.name,
|
||||
@@ -405,6 +453,7 @@ export const ToolWorkshopPage = () => {
|
||||
}
|
||||
setShowFolderForm(false);
|
||||
setSelectedFolder(null);
|
||||
resetFolderForm();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
@@ -442,87 +491,239 @@ export const ToolWorkshopPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h1>Tool Workshop</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs" style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", borderBottom: "1px solid var(--color-border)" }}>
|
||||
{(["types", "configs", "folders"] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={activeTab === tab ? "tab-active" : "tab"}
|
||||
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
||||
{/* Left Sidebar - Tool List */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderBottom: activeTab === tab ? "2px solid var(--color-primary)" : "2px solid transparent",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
{tab === "types" && <><Icon name="code" size="sm" /> Tool Types</>}
|
||||
{tab === "configs" && <><Icon name="settings" size="sm" /> Configs</>}
|
||||
{tab === "folders" && <><Icon name="folder" size="sm" /> Config Folders</>}
|
||||
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
||||
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||
{toolTypes.map((toolType) => (
|
||||
<button
|
||||
key={toolType.id}
|
||||
onClick={() => handleSelectToolType(toolType)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
background: selectedToolTypeId === toolType.id ? "var(--brand)" : "transparent",
|
||||
color: selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedToolTypeId !== toolType.id) {
|
||||
e.currentTarget.style.background = "#ece7df";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedToolTypeId !== toolType.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" }}>
|
||||
{toolType.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
||||
{toolType.category || "Uncategorized"} · Port {toolType.default_port || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
{toolType.is_builtin && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.625rem",
|
||||
textTransform: "uppercase",
|
||||
padding: "0.125rem 0.375rem",
|
||||
borderRadius: "9999px",
|
||||
background: selectedToolTypeId === toolType.id ? "rgba(255,255,255,0.25)" : "var(--muted)",
|
||||
color: selectedToolTypeId === toolType.id ? "white" : "var(--muted)",
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Built-in
|
||||
</span>
|
||||
)}
|
||||
{!toolType.is_builtin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteToolType(toolType.id);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: selectedToolTypeId === toolType.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 tool type"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tool Types Tab */}
|
||||
{activeTab === "types" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Tool Types</h2>
|
||||
<button onClick={openCreateToolType}>
|
||||
<Icon name="add" size="sm" /> Create Tool Type
|
||||
<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 Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showToolTypeForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedToolType ? "Edit" : "Create"} Tool Type</h3>
|
||||
<form onSubmit={handleToolTypeSubmit} className="stack">
|
||||
{/* Right Panel - Editor */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||
{!selectedToolTypeId && !isCreating ? (
|
||||
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||
<Icon name="code" size="lg" />
|
||||
</div>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a tool type</h3>
|
||||
<p style={{ margin: 0 }}>Choose a tool from the list to edit, or create a new one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||
{isCreating ? "Create Tool Type" : selectedToolType?.display_name}
|
||||
</h1>
|
||||
{!isCreating && (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{selectedToolType?.name} · {selectedToolType?.definition_type} · Port {selectedToolType?.default_port}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Panel Tabs */}
|
||||
<div style={{ display: "flex", gap: "0", marginBottom: "1.5rem", borderBottom: "1px solid var(--border)" }}>
|
||||
{(["details", "configs", "folders"] as RightPanelTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setRightPanelTab(tab)}
|
||||
style={{
|
||||
padding: "0.625rem 1.25rem",
|
||||
borderBottom: rightPanelTab === tab ? "2px solid var(--brand)" : "2px solid transparent",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: rightPanelTab === tab ? 600 : 400,
|
||||
color: rightPanelTab === tab ? "var(--brand)" : "var(--muted)",
|
||||
fontSize: "0.9375rem",
|
||||
marginBottom: "-1px",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Details Tab */}
|
||||
{rightPanelTab === "details" && (
|
||||
<form onSubmit={handleToolTypeSubmit} className="stack" style={{ gap: "1rem", maxWidth: "800px" }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="definition-type">Definition Type</label>
|
||||
<select
|
||||
id="definition-type"
|
||||
value={toolTypeForm.definition_type}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
className="form-input"
|
||||
disabled={!isCreating}
|
||||
>
|
||||
<option value="compose">Docker Compose</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="tool-type-name">Name *</label>
|
||||
<input
|
||||
id="tool-type-name"
|
||||
type="text"
|
||||
value={toolTypeForm.name}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, name: e.target.value })}
|
||||
disabled={!!selectedToolType}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, name: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
disabled={!isCreating}
|
||||
placeholder="e.g., code-server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="tool-type-display-name">Display Name *</label>
|
||||
<input
|
||||
id="tool-type-display-name"
|
||||
type="text"
|
||||
value={toolTypeForm.display_name}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, display_name: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="e.g., VS Code Server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-description">Description</label>
|
||||
@@ -530,23 +731,46 @@ export const ToolWorkshopPage = () => {
|
||||
id="tool-type-description"
|
||||
type="text"
|
||||
value={toolTypeForm.description}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, description: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="Optional description"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="tool-type-category">Category</label>
|
||||
<input
|
||||
id="tool-type-category"
|
||||
type="text"
|
||||
value={toolTypeForm.category}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, category: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, category: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="e.g., editor, notebook, ai-assistant"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
<input
|
||||
id="tool-type-default-port"
|
||||
type="number"
|
||||
value={toolTypeForm.default_port}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, default_port: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="e.g., 8443"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
@@ -562,6 +786,7 @@ export const ToolWorkshopPage = () => {
|
||||
} else {
|
||||
setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) });
|
||||
}
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
/>
|
||||
{iface}
|
||||
@@ -570,19 +795,6 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
<input
|
||||
id="tool-type-default-port"
|
||||
type="number"
|
||||
value={toolTypeForm.default_port}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })}
|
||||
placeholder="e.g., 8443"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-template">{toolTypeForm.definition_type === "compose" ? "Compose Template" : "Dockerfile"} *</label>
|
||||
<textarea
|
||||
@@ -594,10 +806,12 @@ export const ToolWorkshopPage = () => {
|
||||
} else {
|
||||
setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value });
|
||||
}
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
rows={10}
|
||||
rows={12}
|
||||
placeholder={toolTypeForm.definition_type === "compose" ? "version: '3.8'\nservices:\n app:\n image: ..." : "FROM node:18\nWORKDIR /app\n..."}
|
||||
className="form-input"
|
||||
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -608,7 +822,10 @@ export const ToolWorkshopPage = () => {
|
||||
id="readiness-command"
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="e.g., curl -f http://localhost:8080"
|
||||
className="form-input"
|
||||
/>
|
||||
@@ -621,7 +838,10 @@ export const ToolWorkshopPage = () => {
|
||||
id="readiness-timeout"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -631,7 +851,10 @@ export const ToolWorkshopPage = () => {
|
||||
id="readiness-interval"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -642,7 +865,10 @@ export const ToolWorkshopPage = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={toolTypeForm.required_variables}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value });
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
placeholder="REPO_PATH, TOOL_NAME"
|
||||
className="form-input"
|
||||
/>
|
||||
@@ -650,85 +876,47 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedToolType ? "Update" : "Create"}</button>
|
||||
<button type="button" onClick={() => setShowToolTypeForm(false)} className="button-secondary">Cancel</button>
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
|
||||
<button type="submit">
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
{isCreating ? "Create Tool Type" : "Save Changes"}
|
||||
</button>
|
||||
{toolTypeDirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCreating) {
|
||||
resetToolTypeForm();
|
||||
} else if (selectedToolType) {
|
||||
populateToolTypeForm(selectedToolType);
|
||||
}
|
||||
}}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Discard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{toolTypes.map((toolType) => (
|
||||
<div key={toolType.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{toolType.display_name}</h3>
|
||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||
</div>
|
||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Type: {toolType.definition_type}</span>
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
{toolType.readiness_probe && (
|
||||
<span>Probe: {toolType.readiness_probe.command}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button onClick={() => openEditToolType(toolType)} className="button-secondary">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button onClick={() => handleDeleteToolType(toolType.id)} className="button-danger">
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configs Tab */}
|
||||
{activeTab === "configs" && (
|
||||
{rightPanelTab === "configs" && selectedToolTypeId && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Tool Configurations</h2>
|
||||
<h3 style={{ margin: 0 }}>Configurations for {selectedToolType?.display_name}</h3>
|
||||
<button onClick={openCreateConfig}>
|
||||
<Icon name="add" size="sm" /> Add Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfigForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedConfig ? "Edit" : "Add"} Config</h3>
|
||||
<form onSubmit={handleConfigSubmit} className="stack">
|
||||
<div className="card stack" style={{ marginBottom: "1rem", padding: "1rem" }}>
|
||||
<h4>{selectedConfig ? "Edit" : "Add"} Config</h4>
|
||||
<form onSubmit={handleConfigSubmit} className="stack" style={{ gap: "0.75rem" }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-tool-type">Tool Type *</label>
|
||||
<select
|
||||
id="config-tool-type"
|
||||
value={configForm.tool_type_id}
|
||||
onChange={(e) => setConfigForm({ ...configForm, tool_type_id: e.target.value })}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tool type...</option>
|
||||
{toolTypes.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>{tt.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-key">Key *</label>
|
||||
<label>Key *</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={configForm.key}
|
||||
onChange={(e) => setConfigForm({ ...configForm, key: e.target.value })}
|
||||
@@ -739,9 +927,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-type">Config Type</label>
|
||||
<label>Config Type</label>
|
||||
<select
|
||||
id="config-type"
|
||||
value={configForm.config_type}
|
||||
onChange={(e) => setConfigForm({ ...configForm, config_type: e.target.value })}
|
||||
className="form-input"
|
||||
@@ -765,9 +952,8 @@ export const ToolWorkshopPage = () => {
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<label>Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={configForm.value}
|
||||
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
||||
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||
@@ -779,9 +965,8 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-port-override">Port Override</label>
|
||||
<label>Port Override</label>
|
||||
<input
|
||||
id="config-port-override"
|
||||
type="number"
|
||||
value={configForm.port_override}
|
||||
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||
@@ -790,9 +975,8 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-start-command">Start Command</label>
|
||||
<label>Start Command</label>
|
||||
<input
|
||||
id="config-start-command"
|
||||
type="text"
|
||||
value={configForm.start_command}
|
||||
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||
@@ -820,7 +1004,8 @@ export const ToolWorkshopPage = () => {
|
||||
onChange={(e) => setConfigForm({ ...configForm, env_vars_json: e.target.value })}
|
||||
placeholder='{"KEY": "value"}'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
rows={3}
|
||||
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -831,7 +1016,8 @@ export const ToolWorkshopPage = () => {
|
||||
onChange={(e) => setConfigForm({ ...configForm, volumes_json: e.target.value })}
|
||||
placeholder='[{"source": "/host", "target": "/container"}]'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
rows={3}
|
||||
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -839,20 +1025,22 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
|
||||
<button type="button" onClick={() => setShowConfigForm(false)} className="button-secondary">Cancel</button>
|
||||
<button type="button" onClick={() => { setShowConfigForm(false); resetConfigForm(); }} className="button-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{configs.length === 0 ? (
|
||||
<p className="muted">No configurations yet.</p>
|
||||
{toolConfigs.length === 0 ? (
|
||||
<p className="muted">No configurations for this tool type yet.</p>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
toolConfigs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
@@ -860,18 +1048,19 @@ export const ToolWorkshopPage = () => {
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
|
||||
<div>
|
||||
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
background: config.config_type === "env" ? "var(--info, #3b82f6)" : "var(--warning, #f59e0b)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
@@ -903,28 +1092,28 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Folders Tab */}
|
||||
{activeTab === "folders" && (
|
||||
{/* Folders Tab */}
|
||||
{rightPanelTab === "folders" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Config Folders</h2>
|
||||
<h3 style={{ margin: 0 }}>Config Folders</h3>
|
||||
<button onClick={openCreateFolder}>
|
||||
<Icon name="add" size="sm" /> Create Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFolderForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||
<form onSubmit={handleFolderSubmit} className="stack">
|
||||
<div className="card stack" style={{ marginBottom: "1rem", padding: "1rem" }}>
|
||||
<h4>{selectedFolder ? "Edit" : "Create"} Config Folder</h4>
|
||||
<form onSubmit={handleFolderSubmit} className="stack" style={{ gap: "0.75rem" }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-name">Name *</label>
|
||||
<label>Name *</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderForm.name}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||
@@ -935,9 +1124,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-description">Description</label>
|
||||
<label>Description</label>
|
||||
<input
|
||||
id="folder-description"
|
||||
type="text"
|
||||
value={folderForm.description}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||
@@ -947,9 +1135,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||
<label>Mount Path *</label>
|
||||
<input
|
||||
id="folder-mount-path"
|
||||
type="text"
|
||||
value={folderForm.mount_path}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||
@@ -960,14 +1147,14 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||
<label>Files (JSON object)</label>
|
||||
<textarea
|
||||
id="folder-files"
|
||||
value={folderForm.files_json}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||
className="form-input"
|
||||
rows={8}
|
||||
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -986,17 +1173,17 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedFolder ? "Update" : "Create"}</button>
|
||||
<button type="button" onClick={() => setShowFolderForm(false)} className="button-secondary">Cancel</button>
|
||||
<button type="button" onClick={() => { setShowFolderForm(false); resetFolderForm(); }} className="button-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{folders.map((folder) => (
|
||||
{toolFolders.map((folder) => (
|
||||
<div key={folder.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{folder.name}</h3>
|
||||
<h4>{folder.name}</h4>
|
||||
{folder.is_active && <span className="badge">Active</span>}
|
||||
</div>
|
||||
<p className="text-secondary">{folder.description || "No description"}</p>
|
||||
@@ -1018,5 +1205,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user