feat(tool-configs): add frontend tool config management page
- Create ToolConfigsPage with tool type selector, config list, and add/edit form - Support both env and file config types - Add route /tool-configs and navigation item - Update API client with tool config endpoints - Build passes successfully
This commit is contained in:
@@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id
|
|||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -31,6 +32,8 @@ from src.services.docker import (
|
|||||||
get_container_status,
|
get_container_status,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
|
write_config_files,
|
||||||
|
write_env_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
@@ -342,9 +345,39 @@ async def start_instance(
|
|||||||
instance.status = "building"
|
instance.status = "building"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Execute docker compose up
|
# Fetch tool configs for this tool type
|
||||||
|
env_vars = {}
|
||||||
|
config_files = {}
|
||||||
|
|
||||||
|
config_query = select(ToolConfig).where(
|
||||||
|
ToolConfig.user_id == user_id,
|
||||||
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
|
).where(
|
||||||
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
config_result = await session.execute(config_query)
|
||||||
|
configs = config_result.scalars().all()
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if config.config_type == "env":
|
||||||
|
env_vars[config.key] = config.value
|
||||||
|
elif config.config_type == "file" and config.file_path:
|
||||||
|
config_files[config.file_path] = config.value
|
||||||
|
|
||||||
|
# Write env file and config files
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_file_path = None
|
||||||
|
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
|
|
||||||
|
if config_files:
|
||||||
|
write_config_files(instance_dir, config_files)
|
||||||
|
|
||||||
|
# Execute docker compose up with env file
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "up"
|
instance.compose_path, "up", env_file=env_file_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
|
|||||||
@@ -56,8 +56,44 @@ def write_compose_file(instance_dir: str, content: str) -> str:
|
|||||||
return str(compose_path)
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||||
|
"""Write environment variables to a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
env_vars: Dictionary of env var names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the env file
|
||||||
|
"""
|
||||||
|
env_path = Path(instance_dir) / ".env"
|
||||||
|
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return str(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
files: Dictionary of file paths (relative to instance dir) to content
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Ensure the path is within the instance directory (security)
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
"""Execute a docker compose command.
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
@@ -65,6 +101,7 @@ def execute_compose_command(
|
|||||||
compose_path: Path to docker-compose.yml
|
compose_path: Path to docker-compose.yml
|
||||||
action: The compose action (up, down, start, stop, restart)
|
action: The compose action (up, down, start, stop, restart)
|
||||||
timeout: Command timeout in seconds
|
timeout: Command timeout in seconds
|
||||||
|
env_file: Optional path to .env file for environment variables
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
@@ -72,6 +109,9 @@ def execute_compose_command(
|
|||||||
instance_dir = Path(compose_path).parent
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", compose_path]
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
|
if env_file:
|
||||||
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
cmd.extend(["up", "-d"])
|
cmd.extend(["up", "-d"])
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ToolConfig {
|
||||||
|
id: string;
|
||||||
|
tool_type_id: string;
|
||||||
|
project_id: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
config_type: string;
|
||||||
|
file_path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateToolConfigRequest {
|
||||||
|
tool_type_id: string;
|
||||||
|
project_id?: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
config_type?: string;
|
||||||
|
file_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listToolConfigs = async (
|
||||||
|
tool_type_id?: string,
|
||||||
|
project_id?: string
|
||||||
|
): Promise<ToolConfig[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||||
|
if (project_id) params.append("project_id", project_id);
|
||||||
|
|
||||||
|
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.configs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolConfig = async (
|
||||||
|
data: CreateToolConfigRequest
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolConfig = async (
|
||||||
|
id: string,
|
||||||
|
data: CreateToolConfigRequest
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tool-configs/${id}`);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
|||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
||||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
||||||
|
{ to: "/tool-configs", label: "Tool Configs", icon: "settings" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import {
|
||||||
|
createToolConfig,
|
||||||
|
deleteToolConfig,
|
||||||
|
listToolConfigs,
|
||||||
|
updateToolConfig,
|
||||||
|
type ToolConfig,
|
||||||
|
} from "../api/tool_configs";
|
||||||
|
|
||||||
|
type ConfigStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export const ToolConfigsPage = () => {
|
||||||
|
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: "",
|
||||||
|
});
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [typesData, configsData] = await Promise.all([
|
||||||
|
listToolTypes(),
|
||||||
|
listToolConfigs(),
|
||||||
|
]);
|
||||||
|
setToolTypes(typesData);
|
||||||
|
setConfigs(configsData);
|
||||||
|
if (typesData.length > 0 && !selectedToolType) {
|
||||||
|
setSelectedToolType(typesData[0].id);
|
||||||
|
}
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, [selectedToolType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaveStatus("saving");
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
tool_type_id: selectedToolType,
|
||||||
|
key: formData.key,
|
||||||
|
value: formData.value,
|
||||||
|
config_type: formData.config_type,
|
||||||
|
file_path: formData.config_type === "file" ? formData.file_path : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingConfig) {
|
||||||
|
await updateToolConfig(editingConfig.id, data);
|
||||||
|
} else {
|
||||||
|
await createToolConfig(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus("saved");
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (config: ToolConfig) => {
|
||||||
|
setEditingConfig(config);
|
||||||
|
setFormData({
|
||||||
|
key: config.key,
|
||||||
|
value: config.value,
|
||||||
|
config_type: config.config_type,
|
||||||
|
file_path: config.file_path || "",
|
||||||
|
});
|
||||||
|
setSelectedToolType(config.tool_type_id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this config?")) return;
|
||||||
|
try {
|
||||||
|
await deleteToolConfig(id);
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
// Error handled by UI state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredConfigs = configs.filter(
|
||||||
|
(c) => c.tool_type_id === selectedToolType
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Loading...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<div className="card stack">
|
||||||
|
<p>Failed to load configurations</p>
|
||||||
|
<button className="secondary-button" onClick={() => void loadData()} type="button">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Manage environment variables and configuration files for your tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Type Selector */}
|
||||||
|
<div className="card">
|
||||||
|
<label htmlFor="tool-type-select">Select Tool</label>
|
||||||
|
<select
|
||||||
|
id="tool-type-select"
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedToolType(e.target.value);
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{toolTypes.map((tool) => (
|
||||||
|
<option key={tool.id} value={tool.id}>
|
||||||
|
{tool.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedTool && (
|
||||||
|
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config List */}
|
||||||
|
<div className="card stack">
|
||||||
|
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<h2>Configuration Variables</h2>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(true);
|
||||||
|
setEditingConfig(null);
|
||||||
|
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredConfigs.length === 0 ? (
|
||||||
|
<p className="muted">No configurations for this tool yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.config_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
||||||
|
{config.config_type === "file" && config.file_path
|
||||||
|
? `File: ${config.file_path}`
|
||||||
|
: "Environment variable"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => void handleDelete(config.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="card stack">
|
||||||
|
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-key">Key</label>
|
||||||
|
<input
|
||||||
|
id="config-key"
|
||||||
|
type="text"
|
||||||
|
value={formData.key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||||
|
placeholder="e.g., OPENAI_API_KEY"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-type">Type</label>
|
||||||
|
<select
|
||||||
|
id="config-type"
|
||||||
|
value={formData.config_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, config_type: e.target.value })
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="env">Environment Variable</option>
|
||||||
|
<option value="file">Configuration File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.config_type === "file" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-file-path">File Path</label>
|
||||||
|
<input
|
||||||
|
id="config-file-path"
|
||||||
|
type="text"
|
||||||
|
value={formData.file_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, file_path: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g., /app/config.json"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-value">Value</label>
|
||||||
|
<textarea
|
||||||
|
id="config-value"
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
formData.config_type === "env"
|
||||||
|
? "Enter value..."
|
||||||
|
: "Enter file contents..."
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
rows={formData.config_type === "file" ? 8 : 2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary-button">
|
||||||
|
{editingConfig ? "Update" : "Add"} Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveStatus === "saved" && (
|
||||||
|
<p className="text-success" style={{ textAlign: "right" }}>
|
||||||
|
Saved successfully!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveStatus === "error" && (
|
||||||
|
<p className="text-error" style={{ textAlign: "right" }}>
|
||||||
|
Failed to save. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import { RepoWorkspace } from "./pages/repo-workspace";
|
|||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { SettingsPage } from "./pages/settings";
|
import { SettingsPage } from "./pages/settings";
|
||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
|
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||||
import { ToolTypesPage } from "./pages/tool-types";
|
import { ToolTypesPage } from "./pages/tool-types";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
@@ -39,6 +40,7 @@ export const AppRouter = () => {
|
|||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||||
|
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user