refactor: split tool-workshop page into tab components (Task 4.1)
- Extract ToolTypesTab, ToolConfigsTab, ConfigFoldersTab from inline page - Each tab is self-contained with own state, API calls, and forms - Slim page to 77 lines (tab switcher + composition only) - Add barrel export for tool-workshop feature components - Add tsconfig path alias for @/* imports Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 4.1
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { listRepositories } from "@/api/git_repositories";
|
||||||
|
import {
|
||||||
|
createInstance,
|
||||||
|
startInstance,
|
||||||
|
} from "@/api/sessions";
|
||||||
|
import { updateUserConfig } from "@/api/settings";
|
||||||
|
import { Icon } from "@/components/icon";
|
||||||
|
import type { Project } from "@/types/project";
|
||||||
|
import type { GitRepository } from "@/types/git-repository";
|
||||||
|
import type { ToolType } from "@/types/tool-type";
|
||||||
|
|
||||||
|
interface CreateSessionFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStatus = "idle" | "creating" | "error";
|
||||||
|
|
||||||
|
export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
|
||||||
|
projects,
|
||||||
|
toolTypes,
|
||||||
|
onCreated,
|
||||||
|
}) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setRepositories([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadRepos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
||||||
|
setCreateError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateStatus("creating");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
selectedProject,
|
||||||
|
selectedRepo,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
);
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setCreateStatus("idle");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
onCreated();
|
||||||
|
} catch {
|
||||||
|
setCreateStatus("error");
|
||||||
|
setCreateError("Failed to create session");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-session-section">
|
||||||
|
<h2>Create New Session</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
className="card stack create-session-form"
|
||||||
|
>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedProject(e.target.value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
disabled={!selectedProject}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{createError && <p className="error-text">{createError}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={createStatus === "creating"}
|
||||||
|
>
|
||||||
|
{createStatus === "creating" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create Session
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@/components/icon";
|
||||||
|
import type { Session } from "@/types/session";
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: Session;
|
||||||
|
variant: "active" | "recent";
|
||||||
|
tunnelHealth?: { healthy: boolean; status_code: number | null; error?: string } | null;
|
||||||
|
isRecreating?: boolean;
|
||||||
|
isStopConfirming?: boolean;
|
||||||
|
isDeleteConfirming?: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRecreateTunnel: () => void;
|
||||||
|
onCancelStop: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionCard: React.FC<SessionCardProps> = ({
|
||||||
|
session,
|
||||||
|
variant,
|
||||||
|
tunnelHealth,
|
||||||
|
isRecreating,
|
||||||
|
isStopConfirming,
|
||||||
|
isDeleteConfirming,
|
||||||
|
onOpen,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
onCancelStop,
|
||||||
|
onCancelDelete,
|
||||||
|
}) => {
|
||||||
|
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
|
||||||
|
|
||||||
|
if (variant === "recent") {
|
||||||
|
return (
|
||||||
|
<div className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">{displayName}</span>
|
||||||
|
<span className="muted">
|
||||||
|
{session.tool_type_name} · {session.project_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="recent-session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ghost-button small"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDeleteConfirming ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active variant
|
||||||
|
return (
|
||||||
|
<div className="card session-card" key={session.id}>
|
||||||
|
<div className="session-info">
|
||||||
|
<h4>{displayName}</h4>
|
||||||
|
<p className="muted">
|
||||||
|
{session.tool_type_name} · {session.project_name}
|
||||||
|
</p>
|
||||||
|
{session.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
{tunnelHealth && !tunnelHealth.healthy && (
|
||||||
|
<span className="status-badge error">tunnel error</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{tunnelHealth && !tunnelHealth.healthy && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onRecreateTunnel}
|
||||||
|
type="button"
|
||||||
|
disabled={isRecreating}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{isRecreating ? "Recreating..." : "Recreate Tunnel"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isStopConfirming ? (
|
||||||
|
<div className="stop-confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDeleteConfirming ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
stopInstance,
|
||||||
|
deleteInstance,
|
||||||
|
checkInstanceHealth,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "@/api/sessions";
|
||||||
|
import type { Session } from "@/types/session";
|
||||||
|
import { SessionCard } from "./SessionCard";
|
||||||
|
|
||||||
|
interface SessionListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
variant: "active" | "recent";
|
||||||
|
onSessionChange?: () => void;
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionList: React.FC<SessionListProps> = ({
|
||||||
|
sessions,
|
||||||
|
variant,
|
||||||
|
onSessionChange,
|
||||||
|
onOpen,
|
||||||
|
}) => {
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{ healthy: boolean; status_code: number | null; error?: string }
|
||||||
|
>
|
||||||
|
>({});
|
||||||
|
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Poll tunnel health every 30 seconds for running instances (active only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant !== "active") return;
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const runningSessions = sessions.filter(
|
||||||
|
(s) => s.status === "running" && s.url,
|
||||||
|
);
|
||||||
|
for (const session of runningSessions) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setTunnelHealth((prev) => ({ ...prev, [session.id]: health }));
|
||||||
|
} catch {
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: {
|
||||||
|
healthy: false,
|
||||||
|
status_code: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [sessions, variant]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
try {
|
||||||
|
await stopInstance(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setStopConfirmId(null);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
setStopConfirmId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
try {
|
||||||
|
await deleteInstance(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRecreateTunnel = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
setRecreatingId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRecreatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(session: Session) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
} else {
|
||||||
|
onOpen?.(session);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="muted">
|
||||||
|
{variant === "active" ? "No active sessions" : "No recent sessions"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{variant === "active" ? (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
variant="active"
|
||||||
|
tunnelHealth={tunnelHealth[session.id] ?? null}
|
||||||
|
isRecreating={recreatingId === session.id}
|
||||||
|
isStopConfirming={stopConfirmId === session.id}
|
||||||
|
isDeleteConfirming={deleteConfirmId === session.id}
|
||||||
|
onOpen={() => handleOpen(session)}
|
||||||
|
onStop={() =>
|
||||||
|
stopConfirmId === session.id
|
||||||
|
? handleStop(session)
|
||||||
|
: setStopConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onDelete={() =>
|
||||||
|
deleteConfirmId === session.id
|
||||||
|
? handleDelete(session)
|
||||||
|
: setDeleteConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onRecreateTunnel={() => handleRecreateTunnel(session)}
|
||||||
|
onCancelStop={() => setStopConfirmId(null)}
|
||||||
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
variant="recent"
|
||||||
|
isDeleteConfirming={deleteConfirmId === session.id}
|
||||||
|
onOpen={() => handleOpen(session)}
|
||||||
|
onStop={() => {}}
|
||||||
|
onDelete={() =>
|
||||||
|
deleteConfirmId === session.id
|
||||||
|
? handleDelete(session)
|
||||||
|
: setDeleteConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onRecreateTunnel={() => {}}
|
||||||
|
onCancelStop={() => {}}
|
||||||
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateSessionForm } from "./CreateSessionForm";
|
||||||
|
export { SessionList } from "./SessionList";
|
||||||
|
export { SessionCard } from "./SessionCard";
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Icon } from "../../../components/icon";
|
||||||
|
import {
|
||||||
|
createConfigFolder,
|
||||||
|
deleteConfigFolder,
|
||||||
|
listConfigFolders,
|
||||||
|
updateConfigFolder,
|
||||||
|
type ConfigFolder,
|
||||||
|
type CreateConfigFolderRequest,
|
||||||
|
type UpdateConfigFolderRequest,
|
||||||
|
} from "../../../api/config_folders";
|
||||||
|
|
||||||
|
export const ConfigFoldersTab = () => {
|
||||||
|
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(null);
|
||||||
|
const [folderForm, setFolderForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files_json: "{}",
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
const [folderError, setFolderError] = useState<string | null>(null);
|
||||||
|
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||||
|
|
||||||
|
const loadFolders = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const data = await listConfigFolders();
|
||||||
|
setFolders(data);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFolders();
|
||||||
|
}, [loadFolders]);
|
||||||
|
|
||||||
|
const openCreateFolder = () => {
|
||||||
|
setFolderForm({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files_json: "{}",
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
setFolderError(null);
|
||||||
|
setShowFolderForm(true);
|
||||||
|
setSelectedFolder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditFolder = (folder: ConfigFolder) => {
|
||||||
|
setFolderForm({
|
||||||
|
name: folder.name,
|
||||||
|
description: folder.description || "",
|
||||||
|
mount_path: folder.mount_path,
|
||||||
|
files_json: JSON.stringify(folder.files, null, 2),
|
||||||
|
is_active: folder.is_active,
|
||||||
|
});
|
||||||
|
setFolderError(null);
|
||||||
|
setShowFolderForm(true);
|
||||||
|
setSelectedFolder(folder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFolderError(null);
|
||||||
|
|
||||||
|
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
|
||||||
|
setFolderError("Name and mount path are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: Record<string, string> | undefined;
|
||||||
|
try {
|
||||||
|
if (folderForm.files_json.trim() && folderForm.files_json.trim() !== "{}") {
|
||||||
|
files = JSON.parse(folderForm.files_json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFolderError("Files must be valid JSON object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
|
||||||
|
name: folderForm.name.trim(),
|
||||||
|
description: folderForm.description.trim() || undefined,
|
||||||
|
mount_path: folderForm.mount_path.trim(),
|
||||||
|
files,
|
||||||
|
is_active: folderForm.is_active,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedFolder) {
|
||||||
|
await updateConfigFolder(selectedFolder.id, data);
|
||||||
|
} else {
|
||||||
|
await createConfigFolder(data as CreateConfigFolderRequest);
|
||||||
|
}
|
||||||
|
setShowFolderForm(false);
|
||||||
|
setSelectedFolder(null);
|
||||||
|
await loadFolders();
|
||||||
|
} catch (err) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setFolderError(axiosError?.response?.data?.detail || "Failed to save folder");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFolder = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this config folder?")) return;
|
||||||
|
try {
|
||||||
|
await deleteConfigFolder(id);
|
||||||
|
await loadFolders();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete folder");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <p className="muted">Loading Config Folders...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="card stack">
|
||||||
|
<p className="text-error">Failed to load config folders.</p>
|
||||||
|
<button onClick={() => void loadFolders()}>
|
||||||
|
<Icon name="refresh" size="sm" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||||
|
<h2>Config Folders</h2>
|
||||||
|
<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="form-group">
|
||||||
|
<label htmlFor="folder-name">Name *</label>
|
||||||
|
<input
|
||||||
|
id="folder-name"
|
||||||
|
type="text"
|
||||||
|
value={folderForm.name}
|
||||||
|
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||||
|
placeholder="e.g., my-dotfiles"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="folder-description">Description</label>
|
||||||
|
<input
|
||||||
|
id="folder-description"
|
||||||
|
type="text"
|
||||||
|
value={folderForm.description}
|
||||||
|
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||||
|
<input
|
||||||
|
id="folder-mount-path"
|
||||||
|
type="text"
|
||||||
|
value={folderForm.mount_path}
|
||||||
|
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||||
|
placeholder="e.g., /home/user"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="folder-files">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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={folderForm.is_active}
|
||||||
|
onChange={(e) => setFolderForm({ ...folderForm, is_active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Active (mount into new instances)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{folderError && <p className="text-error">{folderError}</p>}
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button type="submit">{selectedFolder ? "Update" : "Create"}</button>
|
||||||
|
<button type="button" onClick={() => setShowFolderForm(false)} className="button-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-grid">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<div key={folder.id} className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{folder.name}</h3>
|
||||||
|
{folder.is_active && <span className="badge">Active</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary">{folder.description || "No description"}</p>
|
||||||
|
<div className="tool-type-meta">
|
||||||
|
<span>Mount: {folder.mount_path}</span>
|
||||||
|
<span>Files: {Object.keys(folder.files || {}).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button onClick={() => openEditFolder(folder)} className="button-secondary">
|
||||||
|
<Icon name="edit" size="sm" /> Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteFolder(folder.id)} className="button-danger">
|
||||||
|
<Icon name="delete" size="sm" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Icon } from "../../../components/icon";
|
||||||
|
import {
|
||||||
|
createToolConfig,
|
||||||
|
deleteToolConfig,
|
||||||
|
listToolConfigs,
|
||||||
|
updateToolConfig,
|
||||||
|
type CreateToolConfigRequest,
|
||||||
|
type ToolConfig,
|
||||||
|
} from "../../../api/tool_configs";
|
||||||
|
import { listToolTypes, type ToolType } from "../../../api/tool_types";
|
||||||
|
|
||||||
|
export const ToolConfigsTab = () => {
|
||||||
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
||||||
|
const [configForm, setConfigForm] = useState({
|
||||||
|
tool_type_id: "",
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: "",
|
||||||
|
port_override: "",
|
||||||
|
start_command: "",
|
||||||
|
working_directory: "",
|
||||||
|
env_vars_json: "{}",
|
||||||
|
volumes_json: "[]",
|
||||||
|
});
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
|
const [showConfigForm, setShowConfigForm] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const [cfgs, types] = await Promise.all([
|
||||||
|
listToolConfigs(),
|
||||||
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setConfigs(cfgs);
|
||||||
|
setToolTypes(types);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const openCreateConfig = () => {
|
||||||
|
setConfigForm({
|
||||||
|
tool_type_id: toolTypes[0]?.id || "",
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: "",
|
||||||
|
port_override: "",
|
||||||
|
start_command: "",
|
||||||
|
working_directory: "",
|
||||||
|
env_vars_json: "{}",
|
||||||
|
volumes_json: "[]",
|
||||||
|
});
|
||||||
|
setConfigError(null);
|
||||||
|
setShowConfigForm(true);
|
||||||
|
setSelectedConfig(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditConfig = (config: ToolConfig) => {
|
||||||
|
setConfigForm({
|
||||||
|
tool_type_id: config.tool_type_id,
|
||||||
|
key: config.key,
|
||||||
|
value: config.value,
|
||||||
|
config_type: config.config_type,
|
||||||
|
file_path: config.file_path || "",
|
||||||
|
port_override: config.port_override?.toString() || "",
|
||||||
|
start_command: config.start_command || "",
|
||||||
|
working_directory: config.working_directory || "",
|
||||||
|
env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}",
|
||||||
|
volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]",
|
||||||
|
});
|
||||||
|
setConfigError(null);
|
||||||
|
setShowConfigForm(true);
|
||||||
|
setSelectedConfig(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setConfigError(null);
|
||||||
|
|
||||||
|
if (!configForm.tool_type_id || !configForm.key.trim()) {
|
||||||
|
setConfigError("Tool type and key are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let envVars: Record<string, string> | undefined;
|
||||||
|
let volumes: Array<{ source: string; target: string; type?: string }> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") {
|
||||||
|
envVars = JSON.parse(configForm.env_vars_json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConfigError("Environment variables must be valid JSON");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") {
|
||||||
|
volumes = JSON.parse(configForm.volumes_json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConfigError("Volumes must be valid JSON array");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: CreateToolConfigRequest = {
|
||||||
|
tool_type_id: configForm.tool_type_id,
|
||||||
|
key: configForm.key.trim(),
|
||||||
|
value: configForm.value,
|
||||||
|
config_type: configForm.config_type,
|
||||||
|
file_path: configForm.config_type === "file" ? configForm.file_path : undefined,
|
||||||
|
port_override: configForm.port_override ? Number(configForm.port_override) : undefined,
|
||||||
|
start_command: configForm.start_command.trim() || undefined,
|
||||||
|
working_directory: configForm.working_directory.trim() || undefined,
|
||||||
|
environment_variables: envVars,
|
||||||
|
volumes,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedConfig) {
|
||||||
|
await updateToolConfig(selectedConfig.id, data);
|
||||||
|
} else {
|
||||||
|
await createToolConfig(data);
|
||||||
|
}
|
||||||
|
setShowConfigForm(false);
|
||||||
|
setSelectedConfig(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setConfigError(axiosError?.response?.data?.detail || "Failed to save config");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfig = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this config?")) return;
|
||||||
|
try {
|
||||||
|
await deleteToolConfig(id);
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete config");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <p className="muted">Loading Configurations...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="card stack">
|
||||||
|
<p className="text-error">Failed to load configurations.</p>
|
||||||
|
<button onClick={() => void loadData()}>
|
||||||
|
<Icon name="refresh" size="sm" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||||
|
<h2>Tool Configurations</h2>
|
||||||
|
<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="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>
|
||||||
|
<input
|
||||||
|
id="config-key"
|
||||||
|
type="text"
|
||||||
|
value={configForm.key}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, key: e.target.value })}
|
||||||
|
placeholder="e.g., OPENAI_API_KEY"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="config-type">Config Type</label>
|
||||||
|
<select
|
||||||
|
id="config-type"
|
||||||
|
value={configForm.config_type}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, config_type: e.target.value })}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="env">Environment Variable</option>
|
||||||
|
<option value="file">Configuration File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configForm.config_type === "file" && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>File Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configForm.file_path}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, file_path: e.target.value })}
|
||||||
|
placeholder="e.g., /app/config.json"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="config-value">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..."}
|
||||||
|
className="form-input"
|
||||||
|
rows={configForm.config_type === "file" ? 8 : 2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="config-port-override">Port Override</label>
|
||||||
|
<input
|
||||||
|
id="config-port-override"
|
||||||
|
type="number"
|
||||||
|
value={configForm.port_override}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||||
|
placeholder="e.g., 8080"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="config-start-command">Start Command</label>
|
||||||
|
<input
|
||||||
|
id="config-start-command"
|
||||||
|
type="text"
|
||||||
|
value={configForm.start_command}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||||
|
placeholder="e.g., npm start"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Working Directory</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configForm.working_directory}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, working_directory: e.target.value })}
|
||||||
|
placeholder="e.g., /workspace"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Environment Variables (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
value={configForm.env_vars_json}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, env_vars_json: e.target.value })}
|
||||||
|
placeholder='{"KEY": "value"}'
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Volumes (JSON array)</label>
|
||||||
|
<textarea
|
||||||
|
value={configForm.volumes_json}
|
||||||
|
onChange={(e) => setConfigForm({ ...configForm, volumes_json: e.target.value })}
|
||||||
|
placeholder='[{"source": "/host", "target": "/container"}]'
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configError && <p className="text-error">{configError}</p>}
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
|
||||||
|
<button type="button" onClick={() => setShowConfigForm(false)} 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>
|
||||||
|
) : (
|
||||||
|
configs.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"}
|
||||||
|
{config.port_override && ` · Port: ${config.port_override}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => openEditConfig(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleDeleteConfig(config.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Icon } from "../../../components/icon";
|
||||||
|
import {
|
||||||
|
createToolType,
|
||||||
|
deleteToolType,
|
||||||
|
listToolTypes,
|
||||||
|
updateToolType,
|
||||||
|
type CreateToolTypeRequest,
|
||||||
|
type ReadinessProbe,
|
||||||
|
type ToolType,
|
||||||
|
type UpdateToolTypeRequest,
|
||||||
|
} from "../../../api/tool_types";
|
||||||
|
|
||||||
|
export const ToolTypesTab = () => {
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState<ToolType | null>(null);
|
||||||
|
const [toolTypeForm, setToolTypeForm] = useState({
|
||||||
|
name: "",
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
interfaces: [] as string[],
|
||||||
|
default_port: "",
|
||||||
|
definition_type: "compose" as "compose" | "dockerfile",
|
||||||
|
compose_template: "",
|
||||||
|
dockerfile_template: "",
|
||||||
|
readiness_command: "",
|
||||||
|
readiness_timeout: "30",
|
||||||
|
readiness_interval: "2",
|
||||||
|
required_variables: "",
|
||||||
|
});
|
||||||
|
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||||
|
const [showToolTypeForm, setShowToolTypeForm] = useState(false);
|
||||||
|
|
||||||
|
const loadToolTypes = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const data = await listToolTypes();
|
||||||
|
setToolTypes(data);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadToolTypes();
|
||||||
|
}, [loadToolTypes]);
|
||||||
|
|
||||||
|
const openCreateToolType = () => {
|
||||||
|
setToolTypeForm({
|
||||||
|
name: "",
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
interfaces: [],
|
||||||
|
default_port: "",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "",
|
||||||
|
dockerfile_template: "",
|
||||||
|
readiness_command: "",
|
||||||
|
readiness_timeout: "30",
|
||||||
|
readiness_interval: "2",
|
||||||
|
required_variables: "",
|
||||||
|
});
|
||||||
|
setToolTypeError(null);
|
||||||
|
setShowToolTypeForm(true);
|
||||||
|
setSelectedToolType(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditToolType = (toolType: ToolType) => {
|
||||||
|
setToolTypeForm({
|
||||||
|
name: toolType.name,
|
||||||
|
display_name: toolType.display_name,
|
||||||
|
description: toolType.description || "",
|
||||||
|
category: toolType.category || "",
|
||||||
|
interfaces: toolType.interfaces || [],
|
||||||
|
default_port: toolType.default_port?.toString() || "",
|
||||||
|
definition_type: toolType.definition_type || "compose",
|
||||||
|
compose_template: toolType.compose_template || "",
|
||||||
|
dockerfile_template: toolType.dockerfile_template || "",
|
||||||
|
readiness_command: toolType.readiness_probe?.command || "",
|
||||||
|
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||||
|
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||||
|
required_variables: toolType.required_variables?.join(", ") || "",
|
||||||
|
});
|
||||||
|
setToolTypeError(null);
|
||||||
|
setShowToolTypeForm(true);
|
||||||
|
setSelectedToolType(toolType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setToolTypeError(null);
|
||||||
|
|
||||||
|
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||||
|
setToolTypeError("Name and display name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
|
||||||
|
setToolTypeError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = toolTypeForm.definition_type === "compose"
|
||||||
|
? toolTypeForm.compose_template
|
||||||
|
: toolTypeForm.dockerfile_template;
|
||||||
|
|
||||||
|
if (!template.trim()) {
|
||||||
|
setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = toolTypeForm.required_variables
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0);
|
||||||
|
|
||||||
|
const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim()
|
||||||
|
? {
|
||||||
|
command: toolTypeForm.readiness_command.trim(),
|
||||||
|
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
||||||
|
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
||||||
|
}
|
||||||
|
: 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 {
|
||||||
|
const input: CreateToolTypeRequest = {
|
||||||
|
name: toolTypeForm.name.trim(),
|
||||||
|
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 createToolType(input);
|
||||||
|
}
|
||||||
|
setShowToolTypeForm(false);
|
||||||
|
setSelectedToolType(null);
|
||||||
|
await loadToolTypes();
|
||||||
|
} catch (err) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteToolType = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return;
|
||||||
|
try {
|
||||||
|
await deleteToolType(id);
|
||||||
|
await loadToolTypes();
|
||||||
|
} catch {
|
||||||
|
alert("Failed to delete tool type");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <p className="muted">Loading Tool Types...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="card stack">
|
||||||
|
<p className="text-error">Failed to load tool types.</p>
|
||||||
|
<button onClick={() => void loadToolTypes()}>
|
||||||
|
<Icon name="refresh" size="sm" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showToolTypeForm && (
|
||||||
|
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||||
|
<h3>{selectedToolType ? "Edit" : "Create"} Tool Type</h3>
|
||||||
|
<form onSubmit={handleToolTypeSubmit} className="stack">
|
||||||
|
<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" })}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="compose">Docker Compose</option>
|
||||||
|
<option value="dockerfile">Dockerfile</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<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}
|
||||||
|
placeholder="e.g., code-server"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<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 })}
|
||||||
|
placeholder="e.g., VS Code Server"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-description">Description</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-description"
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.description}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<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 })}
|
||||||
|
placeholder="e.g., editor, notebook, ai-assistant"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Interfaces</label>
|
||||||
|
<div className="checkbox-group">
|
||||||
|
{["web", "terminal"].map((iface) => (
|
||||||
|
<label key={iface} className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolTypeForm.interfaces.includes(iface)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setToolTypeForm({ ...toolTypeForm, interfaces: [...toolTypeForm.interfaces, iface] });
|
||||||
|
} else {
|
||||||
|
setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{iface}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
id="tool-type-template"
|
||||||
|
value={toolTypeForm.definition_type === "compose" ? toolTypeForm.compose_template : toolTypeForm.dockerfile_template}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (toolTypeForm.definition_type === "compose") {
|
||||||
|
setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value });
|
||||||
|
} else {
|
||||||
|
setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={10}
|
||||||
|
placeholder={toolTypeForm.definition_type === "compose" ? "version: '3.8'\nservices:\n app:\n image: ..." : "FROM node:18\nWORKDIR /app\n..."}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||||
|
<input
|
||||||
|
id="readiness-command"
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_command}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||||
|
placeholder="e.g., curl -f http://localhost:8080"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
id="readiness-timeout"
|
||||||
|
type="number"
|
||||||
|
value={toolTypeForm.readiness_timeout}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||||
|
<input
|
||||||
|
id="readiness-interval"
|
||||||
|
type="number"
|
||||||
|
value={toolTypeForm.readiness_interval}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Required Variables (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.required_variables}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
|
||||||
|
placeholder="REPO_PATH, TOOL_NAME"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ToolTypesTab } from "./ToolTypesTab";
|
||||||
|
export { ToolConfigsTab } from "./ToolConfigsTab";
|
||||||
|
export { ConfigFoldersTab } from "./ConfigFoldersTab";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{message && <p>{message}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger-button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { LoadingState } from "./LoadingState";
|
export { LoadingState } from "./LoadingState";
|
||||||
export { ErrorState } from "./ErrorState";
|
export { ErrorState } from "./ErrorState";
|
||||||
export { StatusBadge } from "./StatusBadge";
|
export { StatusBadge } from "./StatusBadge";
|
||||||
|
export { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
|||||||
+46
-482
@@ -2,28 +2,20 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories } from "../api/git_repositories";
|
import { getUserSessions } from "../api/sessions";
|
||||||
import {
|
|
||||||
getUserSessions,
|
|
||||||
deleteInstance,
|
|
||||||
stopInstance,
|
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
|
||||||
recreateInstanceTunnel,
|
|
||||||
createInstance,
|
|
||||||
} from "../api/sessions";
|
|
||||||
import { listToolTypes } from "../api/tool_types";
|
import { listToolTypes } from "../api/tool_types";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig } from "../api/settings";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
import { LoadingState, ErrorState } from "../components/ui";
|
||||||
|
import {
|
||||||
|
CreateSessionForm,
|
||||||
|
SessionList,
|
||||||
|
} from "../components/features/session";
|
||||||
import type { Project } from "../types/project";
|
import type { Project } from "../types/project";
|
||||||
import type { Session } from "../types/session";
|
import type { Session } from "../types/session";
|
||||||
import type { GitRepository } from "../types/git-repository";
|
|
||||||
import type { ToolType } from "../types/tool-type";
|
import type { ToolType } from "../types/tool-type";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { LoadingState } from "../components/ui";
|
|
||||||
import { ErrorState } from "../components/ui";
|
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
type CreateStatus = "idle" | "creating" | "error";
|
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,26 +24,8 @@ export const SessionsPage = () => {
|
|||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
|
||||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<
|
|
||||||
Record<
|
|
||||||
string,
|
|
||||||
{ healthy: boolean; status_code: number | null; error?: string }
|
|
||||||
>
|
|
||||||
>({});
|
|
||||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
@@ -95,58 +69,6 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
|
||||||
useEffect(() => {
|
|
||||||
const checkHealth = async () => {
|
|
||||||
const runningSessions = sessions.filter(
|
|
||||||
(s) => s.status === "running" && s.url,
|
|
||||||
);
|
|
||||||
for (const session of runningSessions) {
|
|
||||||
try {
|
|
||||||
const health = await checkInstanceHealth(
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
session.id,
|
|
||||||
);
|
|
||||||
setTunnelHealth((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[session.id]: health,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setTunnelHealth((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[session.id]: {
|
|
||||||
healthy: false,
|
|
||||||
status_code: null,
|
|
||||||
error: "check failed",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check immediately and then every 30 seconds
|
|
||||||
void checkHealth();
|
|
||||||
const interval = setInterval(() => void checkHealth(), 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [sessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProject) {
|
|
||||||
setRepositories([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loadRepos = async () => {
|
|
||||||
try {
|
|
||||||
const data = await listRepositories(selectedProject);
|
|
||||||
setRepositories(data);
|
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void loadRepos();
|
|
||||||
}, [selectedProject]);
|
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
const activeSessions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sessions.filter((s) =>
|
sessions.filter((s) =>
|
||||||
@@ -168,104 +90,26 @@ export const SessionsPage = () => {
|
|||||||
[sessions, lastSessionId],
|
[sessions, lastSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleOpen = useCallback(
|
||||||
e.preventDefault();
|
(session: Session) => {
|
||||||
setCreateError(null);
|
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
navigate(`/instances/${session.id}/terminal`);
|
||||||
|
} else {
|
||||||
|
navigate(`/projects/${session.project_id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
const handleResumeLast = useCallback(() => {
|
||||||
setCreateError("Project, repository, and tool type are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateStatus("creating");
|
|
||||||
try {
|
|
||||||
const instance = await createInstance(
|
|
||||||
selectedProject,
|
|
||||||
selectedRepo,
|
|
||||||
selectedToolType,
|
|
||||||
displayName || undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-start the instance
|
|
||||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
|
||||||
setCreateStatus("idle");
|
|
||||||
setSelectedProject("");
|
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
setCreateStatus("error");
|
|
||||||
setCreateError("Failed to create session");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async (
|
|
||||||
sessionId: string,
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
|
||||||
setStopConfirmId(null);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
setStopConfirmId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (
|
|
||||||
sessionId: string,
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await deleteInstance(projectId, repoId, sessionId);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
// Remove from local state immediately
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
||||||
} catch {
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
|
||||||
setRecreatingId(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
session.id,
|
|
||||||
);
|
|
||||||
// Refresh sessions to get new URL
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setRecreatingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
|
||||||
if (session.url) {
|
|
||||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
|
||||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
navigate(`/instances/${session.id}/terminal`);
|
|
||||||
} else {
|
|
||||||
navigate(`/projects/${session.project_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeLast = async () => {
|
|
||||||
if (!lastSession) return;
|
if (!lastSession) return;
|
||||||
// Find the project and repo IDs
|
const project = projects.find(
|
||||||
const project = projects.find((p) => p.name === lastSession.project_name);
|
(p) => p.name === lastSession.project_name,
|
||||||
|
);
|
||||||
if (project) {
|
if (project) {
|
||||||
navigate(`/projects/${project.id}`);
|
navigate(`/projects/${project.id}`);
|
||||||
}
|
}
|
||||||
};
|
}, [lastSession, projects, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
@@ -298,7 +142,8 @@ export const SessionsPage = () => {
|
|||||||
"Unnamed Session"}
|
"Unnamed Session"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} ·{" "}
|
{lastSession.tool_type_name} ·{" "}
|
||||||
|
{lastSession.project_name} ·{" "}
|
||||||
{lastSession.repository_name}
|
{lastSession.repository_name}
|
||||||
</p>
|
</p>
|
||||||
{lastSession.url && (
|
{lastSession.url && (
|
||||||
@@ -312,7 +157,9 @@ export const SessionsPage = () => {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<span className={`status-badge ${lastSession.status}`}>
|
<span
|
||||||
|
className={`status-badge ${lastSession.status}`}
|
||||||
|
>
|
||||||
{lastSession.status}
|
{lastSession.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,316 +197,33 @@ export const SessionsPage = () => {
|
|||||||
<span className="badge">{activeSessions.length}</span>
|
<span className="badge">{activeSessions.length}</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{activeSessions.length === 0 ? (
|
<SessionList
|
||||||
<p className="muted">No active sessions</p>
|
sessions={activeSessions}
|
||||||
) : (
|
variant="active"
|
||||||
<div className="sessions-grid">
|
onSessionChange={loadSessions}
|
||||||
{activeSessions.map((session) => (
|
onOpen={handleOpen}
|
||||||
<div className="card session-card" key={session.id}>
|
/>
|
||||||
<div className="session-info">
|
|
||||||
<h4>
|
|
||||||
{session.display_name ||
|
|
||||||
session.tool_type_name ||
|
|
||||||
"Unnamed Session"}
|
|
||||||
</h4>
|
|
||||||
<p className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</p>
|
|
||||||
{session.url && (
|
|
||||||
<p className="session-url">
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{session.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${session.status}`}>
|
|
||||||
{session.status}
|
|
||||||
</span>
|
|
||||||
{tunnelHealth[session.id] &&
|
|
||||||
!tunnelHealth[session.id].healthy && (
|
|
||||||
<span className="status-badge error">
|
|
||||||
tunnel error
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="secondary-button small"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{tunnelHealth[session.id] &&
|
|
||||||
!tunnelHealth[session.id].healthy && (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => void handleRecreateTunnel(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={recreatingId === session.id}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
{recreatingId === session.id
|
|
||||||
? "Recreating..."
|
|
||||||
: "Recreate Tunnel"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{stopConfirmId === session.id ? (
|
|
||||||
<div className="stop-confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleStop(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setStopConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => setStopConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Sessions */}
|
{/* Recent Sessions */}
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
<div className="recent-sessions-section">
|
<div className="recent-sessions-section">
|
||||||
<h2>Recent Sessions</h2>
|
<h2>Recent Sessions</h2>
|
||||||
<div className="recent-sessions-list">
|
<SessionList
|
||||||
{recentSessions.map((session) => (
|
sessions={recentSessions}
|
||||||
<div className="recent-session-item" key={session.id}>
|
variant="recent"
|
||||||
<div className="recent-session-info">
|
onSessionChange={loadSessions}
|
||||||
<span className="recent-session-name">
|
onOpen={handleOpen}
|
||||||
{session.display_name ||
|
/>
|
||||||
session.tool_type_name ||
|
|
||||||
"Unnamed Session"}
|
|
||||||
</span>
|
|
||||||
<span className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="recent-session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ghost-button small"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Session */}
|
{/* Create Session */}
|
||||||
<div className="create-session-section">
|
<CreateSessionForm
|
||||||
<h2>Create New Session</h2>
|
projects={projects}
|
||||||
<form
|
toolTypes={toolTypes}
|
||||||
onSubmit={handleCreate}
|
onCreated={loadSessions}
|
||||||
className="card stack create-session-form"
|
/>
|
||||||
>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-field">
|
|
||||||
Project
|
|
||||||
<select
|
|
||||||
value={selectedProject}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedProject(e.target.value);
|
|
||||||
setSelectedRepo("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select project...</option>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepo}
|
|
||||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
|
||||||
disabled={!selectedProject}
|
|
||||||
>
|
|
||||||
<option value="">Select repository...</option>
|
|
||||||
{repositories.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Tool Type
|
|
||||||
<select
|
|
||||||
value={selectedToolType}
|
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select tool...</option>
|
|
||||||
{toolTypes.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{createError && <p className="error-text">{createError}</p>}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
type="submit"
|
|
||||||
disabled={createStatus === "creating"}
|
|
||||||
>
|
|
||||||
{createStatus === "creating" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create Session
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,11 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Task 3.5 Apply Report: Slim git_repositories and config_profiles Routers
|
||||||
|
|
||||||
|
**Status:** Success
|
||||||
|
|
||||||
|
## Files Created (4)
|
||||||
|
|
||||||
|
- `apps/api/src/services/git/__init__.py` — Package marker
|
||||||
|
- `apps/api/src/services/git/repository.py` — Repository lifecycle (create, delete, list, path helpers, clone/init)
|
||||||
|
- `apps/api/src/services/git/control.py` — Git control operations with repo validation (branch, commit, fetch, pull, push, merge, status)
|
||||||
|
- `apps/api/src/services/git/files.py` — Git file operations with repo validation (list files, get file, update file, list branches)
|
||||||
|
|
||||||
|
## Files Modified (2)
|
||||||
|
|
||||||
|
- `apps/api/src/services/config_profiles.py` — Expanded with:
|
||||||
|
- `check_duplicate_name()` — name uniqueness validation
|
||||||
|
- `profile_to_dict()` — serialization helper
|
||||||
|
- `check_duplicate_include()` — include uniqueness validation
|
||||||
|
- `include_to_dict()` — serialization helper
|
||||||
|
- `check_duplicate_mount_path()` — mount path uniqueness validation
|
||||||
|
- `mount_to_dict()` — serialization helper
|
||||||
|
- `get_or_create_user_config()` — user config retrieval/creation
|
||||||
|
- `validate_default_profiles()` — validate profile ownership for defaults
|
||||||
|
- `get_default_profiles()` / `set_default_profiles()` / `get_default_profile_for_tool_type()` — default profile management
|
||||||
|
- `list_includes_for_profile()` / `list_mounts_for_profile()` — list helpers
|
||||||
|
|
||||||
|
- `apps/api/src/api/git_repositories.py` — Slimmed from ~1,050 to **276 lines**
|
||||||
|
- Removed all subprocess calls (clone, init, preflight)
|
||||||
|
- Removed all inline git utility calls with error handling
|
||||||
|
- Removed verbose docstrings from endpoints
|
||||||
|
- Router now contains only: imports, endpoint definitions, thin handlers delegating to services
|
||||||
|
|
||||||
|
- `apps/api/src/api/config_profiles.py` — Slimmed from ~765 to **299 lines**
|
||||||
|
- Removed inline cycle detection logic (moved to service)
|
||||||
|
- Removed inline duplicate validation (moved to service)
|
||||||
|
- Removed inline response serialization (moved to service)
|
||||||
|
- Removed default profile management logic (moved to service)
|
||||||
|
- Removed include/mount list building logic (moved to service)
|
||||||
|
- Router now contains only: imports, endpoint definitions, thin handlers
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `python3 -m py_compile api/git_repositories.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile api/config_profiles.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/git/repository.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/git/control.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/git/files.py` | ✅ PASS |
|
||||||
|
| `python3 -m py_compile services/config_profiles.py` | ✅ PASS |
|
||||||
|
| `wc -l api/git_repositories.py` | ✅ 276 lines (≤300) |
|
||||||
|
| `wc -l api/config_profiles.py` | ✅ 299 lines (≤300) |
|
||||||
|
| `grep -n "subprocess" api/git_repositories.py` | ✅ 0 results |
|
||||||
|
| `grep -n "subprocess" api/config_profiles.py` | ✅ 0 results |
|
||||||
|
|
||||||
|
## Blockers/Deviations
|
||||||
|
|
||||||
|
- None. Both routers successfully slimmed to under 300 lines.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The history endpoints (get_repository_history, get_repository_commit) still do inline repo validation + git history calls because `services/git/history.py` doesn't exist yet and the existing utility functions in `utils/git_history.py` are already thin wrappers.
|
||||||
|
- `ProjectOverrideWithId` remains in `api/config_folders.py` as noted in Task 3.2 (Pydantic type invariance issue).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Task 4.1 Apply Report: Split tool-workshop Page into Tab Components
|
||||||
|
|
||||||
|
**Status:** Success (with deviation noted)
|
||||||
|
|
||||||
|
## Files Created (4)
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx` (417 lines)
|
||||||
|
- Self-contained tool types list + create/edit form
|
||||||
|
- Manages own `toolTypes`, form state, loading/error state
|
||||||
|
- Imports from `api/tool_types`
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx` (381 lines)
|
||||||
|
- Self-contained configs list + create/edit form
|
||||||
|
- Loads both `toolTypes` (for dropdown) and `configs`
|
||||||
|
- Imports from `api/tool_configs` and `api/tool_types`
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx` (244 lines)
|
||||||
|
- Self-contained folders list + create/edit form
|
||||||
|
- Imports from `api/config_folders`
|
||||||
|
|
||||||
|
- `apps/web/src/components/features/tool-workshop/index.ts` (barrel export)
|
||||||
|
|
||||||
|
## Files Modified (2)
|
||||||
|
|
||||||
|
- `apps/web/src/pages/tool-workshop.tsx` — Slimmed from ~700 lines to **77 lines**
|
||||||
|
- Removed all inline tab state and JSX
|
||||||
|
- Keeps only: `activeTab` state, tab navigation, component composition
|
||||||
|
- Imports tabs from `@/components/features/tool-workshop`
|
||||||
|
|
||||||
|
- `apps/web/tsconfig.json` — Added `baseUrl` and `paths` for `@/*` alias
|
||||||
|
- Required because parallel Task 4.2 files use `@/` imports
|
||||||
|
- Standard Vite path mapping, no build behavior change
|
||||||
|
|
||||||
|
## Deviation from Target
|
||||||
|
|
||||||
|
| File | Target | Actual | Note |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| ToolTypesTab.tsx | ~250 | 417 | Form has 15+ fields; each field is ~8 lines of JSX |
|
||||||
|
| ToolConfigsTab.tsx | ~200 | 381 | Form has 10+ fields plus JSON validation |
|
||||||
|
| ConfigFoldersTab.tsx | ~200 | 244 | Within acceptable range |
|
||||||
|
|
||||||
|
**Rationale:** The tabs are form-heavy components. Each form field requires ~6-10 lines of JSX (label + input + props). Further splitting would create micro-components for individual form fields, which may not improve readability. The page itself is well under target at 77 lines.
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `npm run typecheck` | ✅ PASS — zero errors |
|
||||||
|
| `npm run lint` | ✅ PASS — zero warnings |
|
||||||
|
| `wc -l pages/tool-workshop.tsx` | ✅ 77 lines (≤150 target) |
|
||||||
|
| All 3 tabs compile and import | ✅ PASS |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Further decompose ToolTypesTab and ToolConfigsTab into form-field sub-components if desired (optional, out of current task scope)
|
||||||
|
- Task 4.2 (sessions page split) is in progress in parallel
|
||||||
Reference in New Issue
Block a user