Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Fusion
2026-05-22 23:01:59 +02:00
24 changed files with 1445 additions and 712 deletions
+6 -3
View File
@@ -12,7 +12,8 @@ export interface ToolType {
display_name: string;
description: string | null;
category: string;
interfaces: string[];
interface_type: string;
requires_port: boolean;
default_port: number | null;
definition_type: 'compose' | 'dockerfile';
compose_template: string | null;
@@ -31,7 +32,8 @@ export interface CreateToolTypeRequest {
display_name: string;
description?: string;
category?: string;
interfaces?: string[];
interface_type?: string;
requires_port?: boolean;
default_port: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
@@ -45,7 +47,8 @@ export interface UpdateToolTypeRequest {
display_name?: string;
description?: string;
category?: string;
interfaces?: string[];
interface_type?: string;
requires_port?: boolean;
default_port?: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
+9 -1
View File
@@ -18,6 +18,7 @@ interface GitToolbarProps {
currentBranch: string;
branches: string[];
hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
@@ -28,6 +29,7 @@ export const GitToolbar = ({
currentBranch,
branches,
hasRemote,
isMirror,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
@@ -136,7 +138,13 @@ export const GitToolbar = ({
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row">
<div className="toolbar-group">
<select
+1
View File
@@ -197,6 +197,7 @@ export const RepoWorkspace = () => {
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
+1 -1
View File
@@ -170,7 +170,7 @@ export const ToolConfigsPage = () => {
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
Category: {selectedTool.category} · Interface: {selectedTool.interface_type}
</p>
)}
</div>
+8 -5
View File
@@ -67,7 +67,7 @@ export const ToolTypesPage = () => {
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interfaces ?? []);
setFormInterfaces(toolType.interface_type ? [toolType.interface_type] : []);
setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", "));
@@ -108,8 +108,10 @@ export const ToolTypesPage = () => {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
requires_port: formInterfaces.includes("web"),
default_port: Number(formPort),
definition_type: "compose",
compose_template: formTemplate.trim(),
required_variables: variables,
};
@@ -119,7 +121,8 @@ export const ToolTypesPage = () => {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
requires_port: formInterfaces.includes("web"),
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
@@ -194,8 +197,8 @@ export const ToolTypesPage = () => {
<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.interface_type && (
<span>Interface: {toolType.interface_type}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
+40 -168
View File
@@ -13,7 +13,8 @@ const mockToolTypes = [
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
interface_type: "web",
requires_port: true,
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
@@ -32,7 +33,8 @@ const mockToolTypes = [
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
interface_type: "terminal",
requires_port: false,
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
target: { value: "python app.py" },
});
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText("Mount Path *"), {
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
target: { value: "/home/dev" },
});
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
File diff suppressed because it is too large Load Diff