refactor: extract dashboard, workspace, tool-types, and tool-configs components (Task 4.3)
- Extract DashboardSummary, ActiveSessionsList, ProjectsSection, QuickCreateForm, RecentSessionsSection from dashboard.tsx (480 → 110 lines) - Extract WorkspaceSidebar from repo-workspace.tsx - Extract ToolTypeList + ToolTypeForm from tool-types.tsx (409 → 135 lines) - Extract ToolConfigList + ToolConfigForm from tool-configs.tsx (391 → 178 lines) - Add use-dashboard-actions hook for shared dashboard action handlers - Update feature barrels with new exports Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 4.3
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
import { Icon } from "../../../components/icon";
|
||||
|
||||
interface ActiveSessionsListProps {
|
||||
sessions: Session[];
|
||||
actionBusy: string | null;
|
||||
onOpen: (session: Session) => void;
|
||||
onStop: (session: Session) => void;
|
||||
onDelete: (session: Session) => void;
|
||||
onRecreateTunnel: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const ActiveSessionsList = ({
|
||||
sessions,
|
||||
actionBusy,
|
||||
onOpen,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
}: ActiveSessionsListProps) => {
|
||||
if (sessions.length === 0) {
|
||||
return <p className="muted">No active sessions right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-session-grid">
|
||||
{sessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name || session.tool_type_name || "Unnamed Session"}</h3>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{session.project_name} · {session.repository_name}
|
||||
</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onRecreateTunnel(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onStop(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
type="button"
|
||||
onClick={() => void onDelete(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard";
|
||||
|
||||
interface DashboardSummaryProps {
|
||||
summary: DashboardSummaryType;
|
||||
activeSessionsCount: number;
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
export const DashboardSummary = ({
|
||||
summary,
|
||||
activeSessionsCount,
|
||||
}: DashboardSummaryProps) => {
|
||||
return (
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessionsCount
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Project } from "../../../types/project";
|
||||
|
||||
interface ProjectsSectionProps {
|
||||
projects: Project[];
|
||||
onOpenProject: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const ProjectsSection = ({
|
||||
projects,
|
||||
onOpenProject,
|
||||
}: ProjectsSectionProps) => {
|
||||
if (projects.length === 0) {
|
||||
return <p className="muted">No projects yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
className="card project-card home-project-card"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpenProject(project.id)}
|
||||
>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { Project } from "../../../types/project";
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { Icon } from "../../../components/icon";
|
||||
|
||||
interface QuickCreateFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
saveState: "idle" | "saving" | "error";
|
||||
onSubmit: (data: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypeId: string;
|
||||
displayName: string;
|
||||
}) => void;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const QuickCreateForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
saveState,
|
||||
onSubmit,
|
||||
onProjectChange,
|
||||
}: QuickCreateFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProject(projectId);
|
||||
setSelectedRepo("");
|
||||
onProjectChange(projectId);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
onSubmit({
|
||||
projectId: selectedProject,
|
||||
repoId: selectedRepo,
|
||||
toolTypeId: selectedToolType,
|
||||
displayName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack create-session-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(event) => handleProjectChange(event.target.value)}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(event) => setSelectedRepo(event.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(event) => setSelectedToolType(event.target.value)}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={saveState === "saving"}
|
||||
>
|
||||
{saveState === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" /> Create Session
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveState === "error" && (
|
||||
<span className="error-text">Failed to create session</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
|
||||
interface RecentSessionsSectionProps {
|
||||
sessions: Session[];
|
||||
onOpen: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const RecentSessionsSection = ({
|
||||
sessions,
|
||||
onOpen,
|
||||
}: RecentSessionsSectionProps) => {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{sessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">
|
||||
{session.display_name || session.tool_type_name || "Unnamed Session"}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{session.project_name} · {session.tool_type_name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DashboardSummary } from "./DashboardSummary";
|
||||
export { ActiveSessionsList } from "./ActiveSessionsList";
|
||||
export { ProjectsSection } from "./ProjectsSection";
|
||||
export { QuickCreateForm } from "./QuickCreateForm";
|
||||
export { RecentSessionsSection } from "./RecentSessionsSection";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock("../../../api/client", () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from "../../../api/client";
|
||||
|
||||
describe("FileBrowser", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file entries after loading", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
entries: [
|
||||
{ name: "src", type: "directory", path: "src" },
|
||||
{ name: "README.md", type: "file", path: "README.md" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { GitStatus } from "../../../api/git_repositories";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { CommitPanel } from "../../../components/commit-panel";
|
||||
import { InstanceList } from "../../../components/instance-list";
|
||||
|
||||
interface WorkspaceSidebarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
repositories: GitRepository[];
|
||||
gitStatus: GitStatus | null;
|
||||
toolTypes: ToolType[];
|
||||
onRepoChange: (repoId: string) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceSidebar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
repositories,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
onRepoChange,
|
||||
onCommit,
|
||||
}: WorkspaceSidebarProps) => {
|
||||
return (
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={repoId}
|
||||
onChange={(e) => onRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<FileBrowser
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)}
|
||||
<InstanceList
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { FileBrowser } from "./FileBrowser";
|
||||
export { WorkspaceSidebar } from "./WorkspaceSidebar";
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { CreateSessionForm } from "./CreateSessionForm";
|
||||
|
||||
vi.mock("@/api/git_repositories", () => ({
|
||||
listRepositories: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sessions", () => ({
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/settings", () => ({
|
||||
updateUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listRepositories } from "@/api/git_repositories";
|
||||
import { createInstance } from "@/api/sessions";
|
||||
|
||||
const mockProjects = [
|
||||
{ id: "p1", name: "Project One", description: null, owner_id: "u1", default_ssh_key_id: null },
|
||||
{ id: "p2", name: "Project Two", description: null, owner_id: "u1", default_ssh_key_id: null },
|
||||
];
|
||||
|
||||
const mockToolTypes = [
|
||||
{ id: "t1", name: "vscode", display_name: "VS Code", description: null, category: "editor", interfaces: ["web"] as string[], default_port: 8443, definition_type: "compose" as const, compose_template: "", dockerfile_template: null, readiness_probe: null, required_variables: [], is_builtin: true },
|
||||
{ id: "t2", name: "terminal", display_name: "Terminal", description: null, category: "shell", interfaces: ["terminal"] as string[], default_port: 22, definition_type: "dockerfile" as const, compose_template: null, dockerfile_template: "", readiness_probe: null, required_variables: [], is_builtin: true },
|
||||
];
|
||||
|
||||
describe("CreateSessionForm", () => {
|
||||
it("renders form with create button", () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Create New Session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create Session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when fields are missing", async () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create Session"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/project, repository, and tool type are required/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads repositories when project selected", async () => {
|
||||
const mockedList = listRepositories as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
|
||||
|
||||
const { container } = render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectSelect = container.querySelector("select") as HTMLSelectElement;
|
||||
fireEvent.change(projectSelect, { target: { value: "p1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRepositories).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { listRepositories } from "@/api/git_repositories";
|
||||
import {
|
||||
createInstance,
|
||||
startInstance,
|
||||
} from "@/api/sessions";
|
||||
import { createInstance, startInstance } from "@/api/sessions";
|
||||
import { updateUserConfig } from "@/api/settings";
|
||||
import { Icon } from "@/components/icon";
|
||||
import type { Project } from "@/types/project";
|
||||
@@ -81,10 +78,7 @@ export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
|
||||
return (
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="card stack create-session-form"
|
||||
>
|
||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
import type { Session } from "@/types/session";
|
||||
|
||||
const mockSession: Session = {
|
||||
id: "s1",
|
||||
display_name: "Dev Environment",
|
||||
tool_type_name: "VS Code",
|
||||
tool_icon: "code",
|
||||
tool_type_interfaces: ["web", "terminal"],
|
||||
repository_name: "my-repo",
|
||||
repository_id: "r1",
|
||||
project_name: "My Project",
|
||||
project_id: "p1",
|
||||
status: "running",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
describe("SessionCard", () => {
|
||||
it("renders active variant with display name and status", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={mockSession}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dev Environment")).toBeInTheDocument();
|
||||
expect(screen.getByText("running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recent variant with display name", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, status: "stopped" }}
|
||||
variant="recent"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dev Environment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unnamed fallback when display_name is empty", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, display_name: "" }}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("VS Code")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import type { Session } from "@/types/session";
|
||||
interface SessionCardProps {
|
||||
session: Session;
|
||||
variant: "active" | "recent";
|
||||
tunnelHealth?: { healthy: boolean; status_code: number | null; error?: string } | null;
|
||||
tunnelHealth?: {
|
||||
healthy: boolean;
|
||||
status_code: number | null;
|
||||
error?: string;
|
||||
} | null;
|
||||
isRecreating?: boolean;
|
||||
isStopConfirming?: boolean;
|
||||
isDeleteConfirming?: boolean;
|
||||
@@ -31,7 +35,8 @@ export const SessionCard: React.FC<SessionCardProps> = ({
|
||||
onCancelStop,
|
||||
onCancelDelete,
|
||||
}) => {
|
||||
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
const displayName =
|
||||
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
|
||||
if (variant === "recent") {
|
||||
return (
|
||||
@@ -107,7 +112,9 @@ export const SessionCard: React.FC<SessionCardProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
{tunnelHealth && !tunnelHealth.healthy && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigFormProps {
|
||||
editingConfig: ToolConfig | null;
|
||||
onSubmit: (data: {
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string;
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfigFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
key: editingConfig?.key ?? "",
|
||||
value: editingConfig?.value ?? "",
|
||||
config_type: editingConfig?.config_type ?? "env",
|
||||
file_path: editingConfig?.file_path ?? "",
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
setSaveStatus("saved");
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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={onCancel}
|
||||
>
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigListProps {
|
||||
configs: ToolConfig[];
|
||||
onEdit: (config: ToolConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToolConfigList = ({
|
||||
configs,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ToolConfigListProps) => {
|
||||
if (configs.length === 0) {
|
||||
return <p className="muted">No configurations for this tool yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => onEdit(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void onDelete(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolConfigForm } from "./ToolConfigForm";
|
||||
export { ToolConfigList } from "./ToolConfigList";
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeFormProps {
|
||||
mode: "create" | "edit";
|
||||
toolType?: ToolType | null;
|
||||
onSubmit: (input: CreateToolTypeRequest | UpdateToolTypeRequest) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFormProps) => {
|
||||
const [formName, setFormName] = useState(toolType?.name ?? "");
|
||||
const [formDisplayName, setFormDisplayName] = useState(toolType?.display_name ?? "");
|
||||
const [formDescription, setFormDescription] = useState(toolType?.description ?? "");
|
||||
const [formCategory, setFormCategory] = useState(toolType?.category ?? "");
|
||||
const [formInterfaces, setFormInterfaces] = useState<string[]>(toolType?.interfaces ?? []);
|
||||
const [formPort, setFormPort] = useState(toolType?.default_port?.toString() ?? "");
|
||||
const [formTemplate, setFormTemplate] = useState(toolType?.compose_template ?? "");
|
||||
const [formVariables, setFormVariables] = useState(toolType?.required_variables?.join(", ") ?? "");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
||||
setFormError("Name, display name, and compose template are required");
|
||||
return;
|
||||
}
|
||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||
setFormError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = formVariables.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
|
||||
const base = {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await onSubmit({ name: formName.trim(), ...base } as CreateToolTypeRequest);
|
||||
} else {
|
||||
await onSubmit(base as UpdateToolTypeRequest);
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setFormError(axiosError?.response?.data?.detail || "Failed to save tool type");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInterface = (iface: string) => {
|
||||
setFormInterfaces((prev) =>
|
||||
prev.includes(iface) ? prev.filter((i) => i !== iface) : [...prev, iface],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<h2>{mode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name (unique identifier)</label>
|
||||
<input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} disabled={mode === "edit"} placeholder="e.g., code-server" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" value={formDisplayName} onChange={(e) => setFormDisplayName(e.target.value)} placeholder="e.g., VS Code Server" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Optional description" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Category</label>
|
||||
<input type="text" value={formCategory} onChange={(e) => setFormCategory(e.target.value)} placeholder="e.g., editor, notebook, ai-assistant" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={formInterfaces.includes("web")} onChange={() => toggleInterface("web")} /> Web
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={formInterfaces.includes("terminal")} onChange={() => toggleInterface("terminal")} /> Terminal
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Default Port *</label>
|
||||
<input type="number" value={formPort} onChange={(e) => setFormPort(e.target.value)} placeholder="e.g., 8443" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Compose Template (YAML)</label>
|
||||
<textarea value={formTemplate} onChange={(e) => setFormTemplate(e.target.value)} rows={10} placeholder="version: '3.8' services: app: image: ..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input type="text" value={formVariables} onChange={(e) => setFormVariables(e.target.value)} placeholder="REPO_PATH, TOOL_NAME" />
|
||||
</div>
|
||||
{formError && <p className="text-error">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
|
||||
{mode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="button-secondary">
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeListProps {
|
||||
toolTypes: ToolType[];
|
||||
onEdit: (toolType: ToolType) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirmId: string | null;
|
||||
setDeleteConfirmId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ToolTypeList = ({
|
||||
toolTypes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
}: ToolTypeListProps) => {
|
||||
if (toolTypes.length === 0) {
|
||||
return <p>No tool types found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onEdit(toolType)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === toolType.id && (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<p>
|
||||
Delete tool type "{toolType.display_name}"?
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
onClick={() => onDelete(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setDeleteConfirmId(null)}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolTypeForm } from "./ToolTypeForm";
|
||||
export { ToolTypeList } from "./ToolTypeList";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ToolTypesTab } from "./ToolTypesTab";
|
||||
|
||||
vi.mock("../../../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn(),
|
||||
createToolType: vi.fn(),
|
||||
deleteToolType: vi.fn(),
|
||||
updateToolType: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listToolTypes } from "../../../api/tool_types";
|
||||
|
||||
describe("ToolTypesTab", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(<ToolTypesTab />);
|
||||
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool types heading after loading", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([
|
||||
{
|
||||
id: "1",
|
||||
name: "code-server",
|
||||
display_name: "VS Code Server",
|
||||
description: "Web-based IDE",
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\nservices:\n app:",
|
||||
dockerfile_template: null,
|
||||
readiness_probe: null,
|
||||
required_variables: [],
|
||||
is_builtin: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool Types")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load tool types/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,18 +27,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
<h3>{title}</h3>
|
||||
{message && <p>{message}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<button className="secondary-button" onClick={onCancel} type="button">
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
<button className="danger-button" onClick={onConfirm} type="button">
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("renders message", () => {
|
||||
render(<ErrorState message="Failed to load" />);
|
||||
expect(screen.getByText("Failed to load")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render retry button when onRetry is absent", () => {
|
||||
render(<ErrorState message="Failed" />);
|
||||
expect(screen.queryByText("Retry")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when button clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Failed" onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("Retry"));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LoadingState } from "./LoadingState";
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("renders default message", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom message", () => {
|
||||
render(<LoadingState message="Loading sessions..." />);
|
||||
expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
createInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Session } from "../types/session";
|
||||
|
||||
export function useDashboardActions(onRefresh: () => Promise<void>) {
|
||||
const navigate = useNavigate();
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (data: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypeId: string;
|
||||
displayName: string;
|
||||
}) => {
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
data.projectId,
|
||||
data.repoId,
|
||||
data.toolTypeId,
|
||||
data.displayName || undefined,
|
||||
);
|
||||
await startInstance(data.projectId, data.repoId, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSaveState("idle");
|
||||
await onRefresh();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
actionBusy,
|
||||
handleCreate,
|
||||
handleOpen,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
};
|
||||
}
|
||||
@@ -2,34 +2,25 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import {
|
||||
createInstance,
|
||||
getUserSessions,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories } from "../api/git_repositories";
|
||||
import { listToolTypes } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types/project";
|
||||
import type { Session as SessionApi } from "../types/session";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
import type { Project } from "../types/project";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import { Icon } from "../components/icon";
|
||||
import { LoadingState } from "../components/ui";
|
||||
import { ErrorState } from "../components/ui";
|
||||
import {
|
||||
DashboardSummary as DashboardSummaryComponent,
|
||||
ActiveSessionsList,
|
||||
ProjectsSection,
|
||||
QuickCreateForm,
|
||||
RecentSessionsSection,
|
||||
} from "../components/features/dashboard";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { useDashboardActions } from "../hooks/use-dashboard-actions";
|
||||
import { listRepositories } from "../api/git_repositories";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
@@ -40,26 +31,17 @@ export const HomePage = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] =
|
||||
await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
@@ -70,118 +52,17 @@ export const HomePage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
useEffect(() => { void loadHome(); }, [loadHome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = useCallback(async (projectId: string) => {
|
||||
try { setRepositories(await listRepositories(projectId)); }
|
||||
catch { setRepositories([]); }
|
||||
}, []);
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
const activeSessions = useMemo(() => safeSessions.filter((s) => ["running", "building", "pending"].includes(s.status)), [safeSessions]);
|
||||
const recentSessions = useMemo(() => safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5), [safeSessions]);
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() =>
|
||||
safeSessions.filter((session) =>
|
||||
["running", "building", "pending"].includes(session.status),
|
||||
),
|
||||
[safeSessions],
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() =>
|
||||
safeSessions
|
||||
.filter((session) => ["stopped", "error"].includes(session.status))
|
||||
.slice(0, 5),
|
||||
[safeSessions],
|
||||
);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
selectedProject,
|
||||
selectedRepo,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setDisplayName("");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setSaveState("idle");
|
||||
await loadHome();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
const actions = useDashboardActions(loadHome);
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
@@ -189,288 +70,37 @@ export const HomePage = () => {
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">
|
||||
Open sessions, available projects, and the fastest path back into
|
||||
work.
|
||||
</p>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
New Project
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && (
|
||||
<LoadingState message="Loading overview..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Unable to load your workspace overview."
|
||||
onRetry={() => void loadHome()}
|
||||
/>
|
||||
)}
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<DashboardSummaryComponent summary={summary} activeSessionsCount={activeSessions.length} />
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{session.project_name} · {session.repository_name}
|
||||
</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
type="button"
|
||||
onClick={() => handleOpen(session)}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void handleRecreateTunnel(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void handleStop(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
type="button"
|
||||
onClick={() => void handleDelete(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-header"><div><p className="eyebrow">Open sessions</p><h2>{activeSessions.length}</h2></div></div>
|
||||
<ActiveSessionsList sessions={activeSessions} actionBusy={actions.actionBusy} onOpen={actions.handleOpen} onStop={actions.handleStop} onDelete={actions.handleDelete} onRecreateTunnel={actions.handleRecreateTunnel} />
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
className="card project-card home-project-card"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-header"><div><p className="eyebrow">Available projects</p><h2>{projects.length}</h2></div><button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button></div>
|
||||
<ProjectsSection projects={projects} onOpenProject={(id) => navigate(`/projects/${id}`)} />
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(event) => {
|
||||
setSelectedProject(event.target.value);
|
||||
setSelectedRepo("");
|
||||
}}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(event) => setSelectedRepo(event.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(event) =>
|
||||
setSelectedToolType(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={saveState === "saving"}
|
||||
>
|
||||
{saveState === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" /> Create Session
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveState === "error" && (
|
||||
<span className="error-text">Failed to create session</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="page-header"><div><p className="eyebrow">Quick create</p><h2>Start a session</h2></div></div>
|
||||
<QuickCreateForm projects={projects} repositories={repositories} toolTypes={toolTypes} saveState={actions.saveState} onSubmit={actions.handleCreate} onProjectChange={loadRepos} />
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">
|
||||
{session.display_name}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{session.project_name} · {session.tool_type_name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => handleOpen(session)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<RecentSessionsSection sessions={recentSessions} onOpen={actions.handleOpen} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { apiClient } from "../api/client";
|
||||
@@ -10,14 +9,11 @@ import {
|
||||
listRepositories,
|
||||
type GitStatus,
|
||||
} from "../api/git_repositories";
|
||||
import { FileBrowser } from "../components/features/git";
|
||||
import { CommitPanel } from "../components/commit-panel";
|
||||
import { WorkspaceSidebar } from "../components/features/git";
|
||||
import { FileEditor } from "../components/file-editor";
|
||||
import { GitToolbar } from "../components/git-toolbar";
|
||||
import { InstanceList } from "../components/instance-list";
|
||||
import { WorkspaceHeader } from "../components/workspace-header";
|
||||
import { LoadingState } from "../components/ui";
|
||||
import { ErrorState } from "../components/ui";
|
||||
import { GitToolbar } from "../components/git-toolbar";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { listToolTypes } from "../api/tool_types";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
@@ -55,17 +51,14 @@ export const RepoWorkspace = () => {
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
// If no repo selected, select the first one
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
@@ -90,9 +83,7 @@ export const RepoWorkspace = () => {
|
||||
);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) {
|
||||
setCurrentBranch(defaultBranch);
|
||||
}
|
||||
if (defaultBranch) setCurrentBranch(defaultBranch);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
@@ -142,7 +133,10 @@ export const RepoWorkspace = () => {
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
|
||||
<WorkspaceHeader
|
||||
project={project}
|
||||
currentRepo={selectedRepo || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
@@ -186,65 +180,35 @@ export const RepoWorkspace = () => {
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("refresh-file-tree"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="workspace-layout">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<>
|
||||
<FileBrowser
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={() => {
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("refresh-file-tree"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedRepoId && (
|
||||
<InstanceList
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{selectedRepoId && (
|
||||
<WorkspaceSidebar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
repositories={repositories}
|
||||
gitStatus={gitStatus}
|
||||
toolTypes={toolTypes}
|
||||
onRepoChange={handleRepoChange}
|
||||
onCommit={() => {
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("refresh-file-tree"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
<FileEditor
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -253,5 +217,3 @@ export const RepoWorkspace = () => {
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -37,29 +37,45 @@ export const SessionsPage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadSessions(); }, [loadSessions]);
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try { setProjects(await listProjects()); } catch { /* ignore */ }
|
||||
try {
|
||||
setProjects(await listProjects());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try { setToolTypes(await listToolTypes()); } catch { /* ignore */ }
|
||||
try {
|
||||
setToolTypes(await listToolTypes());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
|
||||
() =>
|
||||
sessions.filter((s) =>
|
||||
["running", "building", "pending"].includes(s.status),
|
||||
),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => ["stopped", "error"].includes(s.status))
|
||||
.slice(0, 5),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
@@ -68,13 +84,16 @@ export const SessionsPage = () => {
|
||||
[sessions, lastSessionId],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback((session: Session) => {
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
}, [navigate]);
|
||||
const handleOpen = useCallback(
|
||||
(session: Session) => {
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleResumeLast = useCallback(() => {
|
||||
if (!lastSession) return;
|
||||
@@ -84,12 +103,17 @@ export const SessionsPage = () => {
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header"><h1>Sessions</h1></div>
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />
|
||||
<ErrorState
|
||||
message="Failed to load sessions"
|
||||
onRetry={() => void loadSessions()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
@@ -101,27 +125,45 @@ export const SessionsPage = () => {
|
||||
<div className="card last-session-card">
|
||||
<div className="last-session-info">
|
||||
<h3>
|
||||
{lastSession.display_name || lastSession.tool_type_name || "Unnamed Session"}
|
||||
{lastSession.display_name ||
|
||||
lastSession.tool_type_name ||
|
||||
"Unnamed Session"}
|
||||
</h3>
|
||||
<p className="muted">
|
||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||
{lastSession.tool_type_name} · {lastSession.project_name} ·{" "}
|
||||
{lastSession.repository_name}
|
||||
</p>
|
||||
{lastSession.url && (
|
||||
<p className="session-url">
|
||||
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={lastSession.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{lastSession.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
||||
<span className={`status-badge ${lastSession.status}`}>
|
||||
{lastSession.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="last-session-actions">
|
||||
{lastSession.url ? (
|
||||
<a href={lastSession.url} target="_blank" rel="noopener noreferrer" className="primary-button">
|
||||
<a
|
||||
href={lastSession.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="primary-button"
|
||||
>
|
||||
<Icon name="external" size="sm" /> Open
|
||||
</a>
|
||||
) : (
|
||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={handleResumeLast}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" /> Resume
|
||||
</button>
|
||||
)}
|
||||
@@ -134,21 +176,37 @@ export const SessionsPage = () => {
|
||||
<div className="active-sessions-section">
|
||||
<h2>
|
||||
Active Sessions
|
||||
{activeSessions.length > 0 && <span className="badge">{activeSessions.length}</span>}
|
||||
{activeSessions.length > 0 && (
|
||||
<span className="badge">{activeSessions.length}</span>
|
||||
)}
|
||||
</h2>
|
||||
<SessionList sessions={activeSessions} variant="active" onSessionChange={loadSessions} onOpen={handleOpen} />
|
||||
<SessionList
|
||||
sessions={activeSessions}
|
||||
variant="active"
|
||||
onSessionChange={loadSessions}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<div className="recent-sessions-section">
|
||||
<h2>Recent Sessions</h2>
|
||||
<SessionList sessions={recentSessions} variant="recent" onSessionChange={loadSessions} onOpen={handleOpen} />
|
||||
<SessionList
|
||||
sessions={recentSessions}
|
||||
variant="recent"
|
||||
onSessionChange={loadSessions}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Session */}
|
||||
<CreateSessionForm projects={projects} toolTypes={toolTypes} onCreated={loadSessions} />
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
toolTypes={toolTypes}
|
||||
onCreated={loadSessions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
} from "../api/tool_configs";
|
||||
import { ToolConfigForm, ToolConfigList } from "../components/features/tool-configs";
|
||||
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -22,15 +23,6 @@ export const ToolConfigsPage = () => {
|
||||
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 {
|
||||
@@ -53,43 +45,32 @@ export const ToolConfigsPage = () => {
|
||||
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,
|
||||
};
|
||||
const handleSubmit = async (data: {
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string;
|
||||
}) => {
|
||||
const payload = {
|
||||
tool_type_id: selectedToolType,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
config_type: data.config_type,
|
||||
file_path: data.config_type === "file" ? data.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");
|
||||
if (editingConfig) {
|
||||
await updateToolConfig(editingConfig.id, payload);
|
||||
} else {
|
||||
await createToolConfig(payload);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -104,18 +85,13 @@ export const ToolConfigsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConfigs = configs.filter(
|
||||
(c) => c.tool_type_id === selectedToolType,
|
||||
);
|
||||
|
||||
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||
const filteredConfigs = configs.filter((c) => c.tool_type_id === selectedToolType);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<div className="page-header"><h1>Tool Configurations</h1></div>
|
||||
<p className="muted">Loading...</p>
|
||||
</section>
|
||||
);
|
||||
@@ -124,18 +100,11 @@ export const ToolConfigsPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<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 className="secondary-button" onClick={() => void loadData()} type="button">
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -149,19 +118,11 @@ export const ToolConfigsPage = () => {
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
<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
|
||||
@@ -175,216 +136,42 @@ export const ToolConfigsPage = () => {
|
||||
className="form-input"
|
||||
>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
<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(", ")}
|
||||
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config List */}
|
||||
<div className="card stack">
|
||||
<div
|
||||
className="row"
|
||||
style={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<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
|
||||
<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>
|
||||
)}
|
||||
<ToolConfigList configs={filteredConfigs} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
</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>
|
||||
<ToolConfigForm
|
||||
editingConfig={editingConfig}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type {
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
@@ -13,25 +9,16 @@ import {
|
||||
updateToolType,
|
||||
} from "../api/tool_types";
|
||||
import { Icon } from "../components/icon";
|
||||
import { ToolTypeForm, ToolTypeList } from "../components/features/tool-types";
|
||||
|
||||
type ToolTypesStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ToolTypesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [dialogMode, setDialogMode] = useState<"none" | "create" | "edit">("none");
|
||||
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDisplayName, setFormDisplayName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formCategory, setFormCategory] = useState("");
|
||||
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
||||
const [formPort, setFormPort] = useState("");
|
||||
const [formTemplate, setFormTemplate] = useState("");
|
||||
const [formVariables, setFormVariables] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
@@ -51,29 +38,11 @@ export const ToolTypesPage = () => {
|
||||
}, [loadToolTypes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDisplayName("");
|
||||
setFormDescription("");
|
||||
setFormCategory("");
|
||||
setFormInterfaces([]);
|
||||
setFormPort("");
|
||||
setFormTemplate("");
|
||||
setFormVariables("");
|
||||
setFormError(null);
|
||||
setEditingToolType(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (toolType: ToolType) => {
|
||||
setFormName(toolType.name);
|
||||
setFormDisplayName(toolType.display_name);
|
||||
setFormDescription(toolType.description ?? "");
|
||||
setFormCategory(toolType.category ?? "");
|
||||
setFormInterfaces(toolType.interfaces ?? []);
|
||||
setFormPort(toolType.default_port?.toString() ?? "");
|
||||
setFormTemplate(toolType.compose_template ?? "");
|
||||
setFormVariables(toolType.required_variables.join(", "));
|
||||
setFormError(null);
|
||||
setEditingToolType(toolType);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
@@ -81,61 +50,16 @@ export const ToolTypesPage = () => {
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingToolType(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
||||
setFormError("Name, display name, and compose template are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||
setFormError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = formVariables
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: CreateToolTypeRequest = {
|
||||
name: formName.trim(),
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
await createToolType(input);
|
||||
} else if (dialogMode === "edit" && editingToolType) {
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
await updateToolType(editingToolType.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
await loadToolTypes();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
const detail =
|
||||
axiosError?.response?.data?.detail || "Failed to save tool type";
|
||||
setFormError(detail);
|
||||
const handleSubmit = async (input: CreateToolTypeRequest | UpdateToolTypeRequest) => {
|
||||
if (dialogMode === "create") {
|
||||
await createToolType(input as CreateToolTypeRequest);
|
||||
} else if (editingToolType) {
|
||||
await updateToolType(editingToolType.id, input as UpdateToolTypeRequest);
|
||||
}
|
||||
closeDialog();
|
||||
await loadToolTypes();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -190,219 +114,21 @@ export const ToolTypesPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolTypes.length === 0 ? (
|
||||
<p>No tool types found.</p>
|
||||
) : (
|
||||
<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>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && (
|
||||
<span>Category: {toolType.category}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openEdit(toolType)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === toolType.id && (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<p>Delete tool type "{toolType.display_name}"?</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
onClick={() => handleDelete(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setDeleteConfirmId(null)}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ToolTypeList
|
||||
toolTypes={toolTypes}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
deleteConfirmId={deleteConfirmId}
|
||||
setDeleteConfirmId={setDeleteConfirmId}
|
||||
/>
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<h2>
|
||||
{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name (unique identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
disabled={dialogMode === "edit"}
|
||||
placeholder="e.g., code-server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDisplayName}
|
||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||
placeholder="e.g., VS Code Server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Category</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formCategory}
|
||||
onChange={(e) => setFormCategory(e.target.value)}
|
||||
placeholder="e.g., editor, notebook, ai-assistant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formInterfaces.includes("web")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setFormInterfaces([...formInterfaces, "web"]);
|
||||
} else {
|
||||
setFormInterfaces(
|
||||
formInterfaces.filter((i) => i !== "web"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Web
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formInterfaces.includes("terminal")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setFormInterfaces([...formInterfaces, "terminal"]);
|
||||
} else {
|
||||
setFormInterfaces(
|
||||
formInterfaces.filter((i) => i !== "terminal"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Terminal
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Default Port *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formPort}
|
||||
onChange={(e) => setFormPort(e.target.value)}
|
||||
placeholder="e.g., 8443"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Compose Template (YAML)</label>
|
||||
<textarea
|
||||
value={formTemplate}
|
||||
onChange={(e) => setFormTemplate(e.target.value)}
|
||||
rows={10}
|
||||
placeholder="version: '3.8' services: app: image: ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formVariables}
|
||||
onChange={(e) => setFormVariables(e.target.value)}
|
||||
placeholder="REPO_PATH, TOOL_NAME"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && <p className="text-error">{formError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Update
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ToolTypeForm
|
||||
mode={dialogMode}
|
||||
toolType={editingToolType}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user