refactor: centralize types and extract seed data (Task 1.1)
- Create types/ directory with canonical domain type definitions - session.ts, tool-instance.ts, tool-type.ts, git-repository.ts - config-folder.ts, tool-config.ts, project.ts, user.ts, api-response.ts - Move inline types from api modules to types/ with backward-compatible re-exports - Update all consumers (pages, components, state) to import from types/ - Extract seed_builtin_tool_types from main.py to seeds/builtin_tool_types.py - Ensure Session, ToolInstance, ToolType, GitRepository defined exactly once Quality gates: tsc (pass), eslint (pass), Python syntax (pass) Refs: repo-restructure Task 1.1
This commit is contained in:
@@ -1,77 +1,77 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
} from "../types/config-folder";
|
||||
|
||||
export type {
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
} from "../types/config-folder";
|
||||
|
||||
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
||||
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||
return response.data;
|
||||
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createConfigFolder = async (
|
||||
data: CreateConfigFolderRequest
|
||||
data: CreateConfigFolderRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||
return response.data;
|
||||
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateConfigFolder = async (
|
||||
id: string,
|
||||
data: UpdateConfigFolderRequest
|
||||
id: string,
|
||||
data: UpdateConfigFolderRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/config-folders/${id}`);
|
||||
await apiClient.delete(`/config-folders/${id}`);
|
||||
};
|
||||
|
||||
export const addProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.post<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string
|
||||
id: string,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||
};
|
||||
|
||||
@@ -1,179 +1,191 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
CommitDetail,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
CommitDetail,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
} from "../types/git-repository";
|
||||
|
||||
export type {
|
||||
CommitDetail,
|
||||
CommitHistoryEntry,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
CommitDetail,
|
||||
CommitHistoryEntry,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
} from "../types/git-repository";
|
||||
|
||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
||||
return response.data;
|
||||
const response = await apiClient.post("/projects/repositories/parse-url", {
|
||||
url,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
return response.data;
|
||||
export async function listRepositories(
|
||||
projectId: string,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createRepository(
|
||||
projectId: string,
|
||||
data: GitRepositoryCreate
|
||||
projectId: string,
|
||||
data: GitRepositoryCreate,
|
||||
): Promise<GitRepository> {
|
||||
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
export async function deleteRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
}
|
||||
|
||||
export async function getRepositoryHistory(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
limit?: number
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
limit?: number,
|
||||
): Promise<CommitHistoryResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (branch) searchParams.set("branch", branch);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
const queryString = searchParams.toString();
|
||||
const params = queryString ? `?${queryString}` : "";
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`);
|
||||
return response.data;
|
||||
const searchParams = new URLSearchParams();
|
||||
if (branch) searchParams.set("branch", branch);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
const queryString = searchParams.toString();
|
||||
const params = queryString ? `?${queryString}` : "";
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/history${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getCommitDetail(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
commitHash: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
commitHash: string,
|
||||
): Promise<CommitDetail> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getRepositoryStatus(
|
||||
projectId: string,
|
||||
repoId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<GitStatus> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/status`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/status`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
name: string,
|
||||
baseBranch: string = "HEAD"
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
name: string,
|
||||
baseBranch: string = "HEAD",
|
||||
): Promise<{ message: string; branch: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
||||
{ name, base_branch: baseBranch }
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
||||
{ name, base_branch: baseBranch },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branchName: string,
|
||||
force: boolean = false
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branchName: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function checkoutBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch: string,
|
||||
): Promise<{ message: string; branch: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
||||
{ branch }
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
||||
{ branch },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
message: string,
|
||||
files?: string[]
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
message: string,
|
||||
files?: string[],
|
||||
): Promise<CommitResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/commit`,
|
||||
{ message, files }
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/commit`,
|
||||
{ message, files },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchRepository(
|
||||
projectId: string,
|
||||
repoId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/fetch`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/fetch`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pullRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
): Promise<{ message: string }> {
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/pull${params}`
|
||||
);
|
||||
return response.data;
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/pull${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pushRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
): Promise<{ message: string }> {
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/push${params}`
|
||||
);
|
||||
return response.data;
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/push${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function mergeBranches(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
sourceBranch: string,
|
||||
targetBranch?: string,
|
||||
message?: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
sourceBranch: string,
|
||||
targetBranch?: string,
|
||||
message?: string,
|
||||
): Promise<MergeResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/merge`,
|
||||
{ source_branch: sourceBranch, target_branch: targetBranch, message }
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/merge`,
|
||||
{ source_branch: sourceBranch, target_branch: targetBranch, message },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -6,97 +6,97 @@ export type { Session } from "../types/session";
|
||||
export type { ToolInstance } from "../types/tool-instance";
|
||||
|
||||
export async function listInstances(
|
||||
projectId: string,
|
||||
repoId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<ToolInstance[]> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`
|
||||
);
|
||||
return response.data.instances;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
);
|
||||
return response.data.instances;
|
||||
}
|
||||
|
||||
export async function createInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function stopInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function restartInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
||||
);
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<Session[]> {
|
||||
const response = await apiClient.get("/users/me/sessions");
|
||||
return response.data.sessions;
|
||||
const response = await apiClient.get("/users/me/sessions");
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export async function checkInstanceHealth(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function recreateInstanceTunnel(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -4,46 +4,49 @@ import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||
export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||
|
||||
export const listToolConfigs = async (
|
||||
tool_type_id?: string,
|
||||
project_id?: string
|
||||
tool_type_id?: string,
|
||||
project_id?: string,
|
||||
): Promise<ToolConfig[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||
if (project_id) params.append("project_id", project_id);
|
||||
const params = new URLSearchParams();
|
||||
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||
if (project_id) params.append("project_id", project_id);
|
||||
|
||||
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs?${params.toString()}`
|
||||
);
|
||||
return response.data.configs;
|
||||
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs?${params.toString()}`,
|
||||
);
|
||||
return response.data.configs;
|
||||
};
|
||||
|
||||
export const createToolConfig = async (
|
||||
data: CreateToolConfigRequest
|
||||
data: CreateToolConfigRequest,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
|
||||
return response.data.configs[0];
|
||||
const response = await apiClient.post<{ configs: ToolConfig[] }>(
|
||||
"/tool-configs",
|
||||
data,
|
||||
);
|
||||
return response.data.configs[0];
|
||||
};
|
||||
|
||||
export const updateToolConfig = async (
|
||||
id: string,
|
||||
data: CreateToolConfigRequest
|
||||
id: string,
|
||||
data: CreateToolConfigRequest,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs/${id}`,
|
||||
data
|
||||
);
|
||||
return response.data.configs[0];
|
||||
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data.configs[0];
|
||||
};
|
||||
|
||||
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tool-configs/${id}`);
|
||||
await apiClient.delete(`/tool-configs/${id}`);
|
||||
};
|
||||
|
||||
export const getToolConfigDefaults = async (
|
||||
toolTypeId: string
|
||||
toolTypeId: string,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.get<ToolConfig>(
|
||||
`/tool-configs/defaults/${toolTypeId}`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get<ToolConfig>(
|
||||
`/tool-configs/defaults/${toolTypeId}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import { apiClient } from "./client";
|
||||
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
|
||||
import type {
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
|
||||
export type { ReadinessProbe, ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type";
|
||||
export type {
|
||||
ReadinessProbe,
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
|
||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||
return response.data;
|
||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
|
||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||
return response.data;
|
||||
export const createToolType = async (
|
||||
data: CreateToolTypeRequest,
|
||||
): Promise<ToolType> => {
|
||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
|
||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||
return response.data;
|
||||
export const updateToolType = async (
|
||||
id: string,
|
||||
data: UpdateToolTypeRequest,
|
||||
): Promise<ToolType> => {
|
||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteToolType = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tool-types/${id}`);
|
||||
await apiClient.delete(`/tool-types/${id}`);
|
||||
};
|
||||
|
||||
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
||||
return response.data;
|
||||
export const validateToolType = async (
|
||||
id: string,
|
||||
): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
||||
`/tool-types/${id}/validate`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -10,117 +10,123 @@ import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const ACTIVE_STATUSES = ["running", "building", "pending"];
|
||||
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
const isRunning = session.status === "running";
|
||||
const displayName =
|
||||
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className="nav-item session-item"
|
||||
title={`${displayName} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">{displayName}</span>
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className="nav-item session-item"
|
||||
title={`${displayName} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">{displayName}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppShell = () => {
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isHome = item.to === "/";
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isHome && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions
|
||||
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
||||
.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isHome = item.to === "/";
|
||||
const activeCount = sessions.filter(
|
||||
(s) => s.status === "running",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isHome && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
<main className="shell-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
|
||||
0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions
|
||||
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
||||
.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="shell-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,356 +4,385 @@ import { Icon } from "./icon";
|
||||
import type { ToolInstance } from "../types/tool-instance";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import {
|
||||
checkInstanceHealth,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
listInstances,
|
||||
recreateInstanceTunnel,
|
||||
restartInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
checkInstanceHealth,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
listInstances,
|
||||
recreateInstanceTunnel,
|
||||
restartInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
} from "../api/sessions";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface InstanceListProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypes: ToolType[];
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypes: ToolType[];
|
||||
}
|
||||
|
||||
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||
export const InstanceList = ({
|
||||
projectId,
|
||||
repoId,
|
||||
toolTypes,
|
||||
}: InstanceListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listInstances(projectId, repoId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
setError("Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInstances();
|
||||
}, [loadInstances]);
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<
|
||||
Record<string, { healthy: boolean; lastCheck: number }>
|
||||
>({});
|
||||
|
||||
// Health check polling
|
||||
useEffect(() => {
|
||||
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
|
||||
if (runningInstances.length === 0) return;
|
||||
const loadInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listInstances(projectId, repoId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
setError("Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
const checkHealth = async () => {
|
||||
for (const instance of runningInstances) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(projectId, repoId, instance.id);
|
||||
setHealthStatus(prev => ({
|
||||
...prev,
|
||||
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
|
||||
}));
|
||||
} catch {
|
||||
setHealthStatus(prev => ({
|
||||
...prev,
|
||||
[instance.id]: { healthy: false, lastCheck: Date.now() }
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void loadInstances();
|
||||
}, [loadInstances]);
|
||||
|
||||
// Check immediately
|
||||
void checkHealth();
|
||||
|
||||
// Then every 30 seconds
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [instances, projectId, repoId]);
|
||||
// Health check polling
|
||||
useEffect(() => {
|
||||
const runningInstances = instances.filter(
|
||||
(i) => i.status === "running" && i.url?.startsWith("http"),
|
||||
);
|
||||
if (runningInstances.length === 0) return;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedToolType) return;
|
||||
setError(null);
|
||||
try {
|
||||
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
|
||||
setShowCreate(false);
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to create instance");
|
||||
}
|
||||
};
|
||||
const checkHealth = async () => {
|
||||
for (const instance of runningInstances) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
projectId,
|
||||
repoId,
|
||||
instance.id,
|
||||
);
|
||||
setHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() },
|
||||
}));
|
||||
} catch {
|
||||
setHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[instance.id]: { healthy: false, lastCheck: Date.now() },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (instanceId: string) => {
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
}
|
||||
};
|
||||
// Check immediately
|
||||
void checkHealth();
|
||||
|
||||
const handleStop = async (instanceId: string) => {
|
||||
try {
|
||||
await stopInstance(projectId, repoId, instanceId);
|
||||
setStopConfirmId(null);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to stop instance");
|
||||
}
|
||||
};
|
||||
// Then every 30 seconds
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [instances, projectId, repoId]);
|
||||
|
||||
const handleRestart = async (instanceId: string) => {
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
}
|
||||
};
|
||||
const handleCreate = async () => {
|
||||
if (!selectedToolType) return;
|
||||
setError(null);
|
||||
try {
|
||||
await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
);
|
||||
setShowCreate(false);
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to create instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, instanceId);
|
||||
// Update state immediately instead of reloading
|
||||
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||
} catch {
|
||||
setError("Failed to delete instance");
|
||||
}
|
||||
};
|
||||
const handleStart = async (instanceId: string) => {
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (instanceId: string) => {
|
||||
try {
|
||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to recreate tunnel");
|
||||
}
|
||||
};
|
||||
const handleStop = async (instanceId: string) => {
|
||||
try {
|
||||
await stopInstance(projectId, repoId, instanceId);
|
||||
setStopConfirmId(null);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to stop instance");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "var(--success)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "pending":
|
||||
case "building":
|
||||
return "var(--warning)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
};
|
||||
const handleRestart = async (instanceId: string) => {
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
}
|
||||
};
|
||||
|
||||
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||
if (instance.status !== "running") return false;
|
||||
if (!instance.url?.startsWith("http")) return false;
|
||||
const health = healthStatus[instance.id];
|
||||
if (!health) return false;
|
||||
return !health.healthy;
|
||||
};
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, instanceId);
|
||||
// Update state immediately instead of reloading
|
||||
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||
} catch {
|
||||
setError("Failed to delete instance");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="instance-list">
|
||||
<div className="instance-list-header">
|
||||
<h3>Tool Instances</h3>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Launch Tool
|
||||
</button>
|
||||
</div>
|
||||
const handleRecreateTunnel = async (instanceId: string) => {
|
||||
try {
|
||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to recreate tunnel");
|
||||
}
|
||||
};
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "var(--success)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "pending":
|
||||
case "building":
|
||||
return "var(--warning)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
};
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Loading instances...</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||
) : (
|
||||
<div className="instance-grid">
|
||||
{instances.map((instance) => (
|
||||
<div key={instance.id} className="instance-card">
|
||||
<div className="instance-info">
|
||||
<div className="instance-name">{instance.display_name || instance.tool_type_name || "Unnamed Instance"}</div>
|
||||
<div className="instance-meta">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||
/>
|
||||
{instance.status}
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<span className="error-badge" title="Tunnel unreachable">
|
||||
<Icon name="warning" size="sm" />
|
||||
tunnel error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||
<>
|
||||
<a
|
||||
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<button
|
||||
className="secondary-button small warning"
|
||||
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||
type="button"
|
||||
title="Recreate tunnel"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Fix Tunnel
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
Terminal
|
||||
</button>
|
||||
)}
|
||||
{instance.status !== "running" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleStart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
{instance.status === "running" && (
|
||||
<>
|
||||
{stopConfirmId === instance.id ? (
|
||||
<div className="inline-confirm">
|
||||
<span>Stop?</span>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleRestart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleDelete(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||
if (instance.status !== "running") return false;
|
||||
if (!instance.url?.startsWith("http")) return false;
|
||||
const health = healthStatus[instance.id];
|
||||
if (!health) return false;
|
||||
return !health.healthy;
|
||||
};
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<div className="stack">
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!selectedToolType}
|
||||
type="button"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="instance-list">
|
||||
<div className="instance-list-header">
|
||||
<h3>Tool Instances</h3>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Launch Tool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Loading instances...</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||
) : (
|
||||
<div className="instance-grid">
|
||||
{instances.map((instance) => (
|
||||
<div key={instance.id} className="instance-card">
|
||||
<div className="instance-info">
|
||||
<div className="instance-name">
|
||||
{instance.display_name ||
|
||||
instance.tool_type_name ||
|
||||
"Unnamed Instance"}
|
||||
</div>
|
||||
<div className="instance-meta">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||
/>
|
||||
{instance.status}
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<span className="error-badge" title="Tunnel unreachable">
|
||||
<Icon name="warning" size="sm" />
|
||||
tunnel error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" &&
|
||||
instance.url &&
|
||||
instance.tool_type_interfaces.includes("web") && (
|
||||
<>
|
||||
<a
|
||||
href={
|
||||
instance.url.startsWith("http")
|
||||
? instance.url
|
||||
: `${API_BASE_URL}${instance.url}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<button
|
||||
className="secondary-button small warning"
|
||||
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||
type="button"
|
||||
title="Recreate tunnel"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Fix Tunnel
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" &&
|
||||
instance.tool_type_interfaces.includes("terminal") && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() =>
|
||||
navigate(`/instances/${instance.id}/terminal`)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
Terminal
|
||||
</button>
|
||||
)}
|
||||
{instance.status !== "running" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleStart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
{instance.status === "running" && (
|
||||
<>
|
||||
{stopConfirmId === instance.id ? (
|
||||
<div className="inline-confirm">
|
||||
<span>Stop?</span>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleRestart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleDelete(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<div className="stack">
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!selectedToolType}
|
||||
type="button"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,94 +7,95 @@ import { RepositoryCreateDialog } from "./repository-create-dialog";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
export const RepositoriesSettingsTab: React.FC = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||
} catch {
|
||||
setError("Failed to delete repository");
|
||||
}
|
||||
};
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
if (!window.confirm("Are you sure you want to delete this repository?"))
|
||||
return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||
} catch {
|
||||
setError("Failed to delete repository");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="repositories-settings-tab">
|
||||
<div className="page-header">
|
||||
<h2>Repositories</h2>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
return (
|
||||
<div className="repositories-settings-tab">
|
||||
<div className="page-header">
|
||||
<h2>Repositories</h2>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="repositories-list">
|
||||
{repositories.length === 0 ? (
|
||||
<p>No repositories yet.</p>
|
||||
) : (
|
||||
repositories.map((repo) => (
|
||||
<div key={repo.id} className="repository-card">
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
<p>{repo.remote_url}</p>
|
||||
<span className="repo-type">
|
||||
{repo.is_mirror ? "Mirror" : "Clone"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(repo.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="repositories-list">
|
||||
{repositories.length === 0 ? (
|
||||
<p>No repositories yet.</p>
|
||||
) : (
|
||||
repositories.map((repo) => (
|
||||
<div key={repo.id} className="repository-card">
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
<p>{repo.remote_url}</p>
|
||||
<span className="repo-type">
|
||||
{repo.is_mirror ? "Mirror" : "Clone"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(repo.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Add Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Add Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,294 +1,326 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GitRepositoryCreate, URLParseResult } from "../types/git-repository";
|
||||
import type {
|
||||
GitRepositoryCreate,
|
||||
URLParseResult,
|
||||
} from "../types/git-repository";
|
||||
import { createRepository, parseGitUrl } from "../api/git_repositories";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
type CreateMode = "clone" | "blank";
|
||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||
type UrlValidationStatus =
|
||||
| "idle"
|
||||
| "validating"
|
||||
| "valid"
|
||||
| "needs-parsing"
|
||||
| "invalid";
|
||||
|
||||
interface RepositoryCreateDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [urlValidation, setUrlValidation] = useState<{
|
||||
status: UrlValidationStatus;
|
||||
result: URLParseResult | null;
|
||||
}>({ status: "idle", result: null });
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
export const RepositoryCreateDialog = ({
|
||||
projectId,
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [urlValidation, setUrlValidation] = useState<{
|
||||
status: UrlValidationStatus;
|
||||
result: URLParseResult | null;
|
||||
}>({ status: "idle", result: null });
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(false);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
};
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(false);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
}
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
}
|
||||
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
||||
}
|
||||
};
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(
|
||||
typeof detail === "string" ? detail : "Failed to create repository",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={handleClose} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a
|
||||
blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
SSH target: git@git.commumedia.org:{owner || "owner"}/
|
||||
{repoName || "repo"}.git
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">
|
||||
Validating...
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" &&
|
||||
urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a
|
||||
browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">
|
||||
Suggested: {urlValidation.result.base_url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
{createMode === "clone"
|
||||
? "Clone Repository"
|
||||
: "Create Blank Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+437
-295
@@ -2,7 +2,14 @@ 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 {
|
||||
createInstance,
|
||||
getUserSessions,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories } from "../api/git_repositories";
|
||||
import { listToolTypes } from "../api/tool_types";
|
||||
@@ -16,326 +23,461 @@ import { Icon } from "../components/icon";
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
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 navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
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(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] =
|
||||
await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
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 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;
|
||||
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");
|
||||
}
|
||||
};
|
||||
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 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 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 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 handleRecreateTunnel = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => void loadHome()}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
@@ -2,138 +2,151 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
import {
|
||||
deleteRepository,
|
||||
listRepositories,
|
||||
} from "../api/git_repositories";
|
||||
import { deleteRepository, listRepositories } from "../api/git_repositories";
|
||||
import { Icon } from "../components/icon";
|
||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||
|
||||
type RepoStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const GitRepositoriesPage = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Repositories</h1>
|
||||
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Repository
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Repositories</h1>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
New Repository
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
||||
{isEmpty && (
|
||||
<p className="muted">
|
||||
No repositories yet. Create your first repository above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
{repo.is_mirror && repo.remote_url && (
|
||||
<p className="muted">Mirror of {repo.remote_url}</p>
|
||||
)}
|
||||
<p className="muted">{repo.path}</p>
|
||||
</div>
|
||||
<div className="repository-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}/history`)}
|
||||
type="button"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
{deleteConfirmId === repo.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
{repo.is_mirror && repo.remote_url && (
|
||||
<p className="muted">Mirror of {repo.remote_url}</p>
|
||||
)}
|
||||
<p className="muted">{repo.path}</p>
|
||||
</div>
|
||||
<div className="repository-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${projectId}/repositories/${repo.id}/history`,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
{deleteConfirmId === repo.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ import { apiClient } from "../api/client";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import {
|
||||
getRepositoryStatus,
|
||||
listRepositories,
|
||||
type GitStatus,
|
||||
getRepositoryStatus,
|
||||
listRepositories,
|
||||
type GitStatus,
|
||||
} from "../api/git_repositories";
|
||||
import { CommitPanel } from "../components/commit-panel";
|
||||
import { FileEditor } from "../components/file-editor";
|
||||
@@ -21,374 +21,382 @@ import { listToolTypes } from "../api/tool_types";
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
interface FileTreeEntry {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
size?: number;
|
||||
mode?: string;
|
||||
last_commit?: {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
} | null;
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
size?: number;
|
||||
mode?: string;
|
||||
last_commit?: {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo")
|
||||
);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo"),
|
||||
);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
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);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
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);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
|
||||
);
|
||||
const branchList = response.data.branches.map((b: { name: string }) => b.name);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) {
|
||||
setCurrentBranch(defaultBranch);
|
||||
}
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`,
|
||||
);
|
||||
const branchList = response.data.branches.map(
|
||||
(b: { name: string }) => b.name,
|
||||
);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) {
|
||||
setCurrentBranch(defaultBranch);
|
||||
}
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||
setGitStatus(data);
|
||||
} catch {
|
||||
setGitStatus(null);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||
setGitStatus(data);
|
||||
} catch {
|
||||
setGitStatus(null);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
}
|
||||
}, []);
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
void loadRepositories();
|
||||
void loadToolTypes();
|
||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
void loadRepositories();
|
||||
void loadToolTypes();
|
||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
}, [loadBranches, loadGitStatus]);
|
||||
useEffect(() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
}, [loadBranches, loadGitStatus]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader
|
||||
project={project}
|
||||
currentRepo={selectedRepo || null}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
)}
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<>
|
||||
{selectedRepoId && (
|
||||
<GitToolbar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
currentBranch={currentBranch}
|
||||
branches={branches}
|
||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||
onBranchChange={(branch) => {
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
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>
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<>
|
||||
{selectedRepoId && (
|
||||
<GitToolbar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
currentBranch={currentBranch}
|
||||
branches={branches}
|
||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||
onBranchChange={(branch) => {
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
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 && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// File Browser Component
|
||||
const FileBrowser = ({
|
||||
projectId,
|
||||
repoId,
|
||||
gitStatus,
|
||||
projectId,
|
||||
repoId,
|
||||
gitStatus,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus: GitStatus | null;
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus: GitStatus | null;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const path = searchParams.get("path") || "";
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const path = searchParams.get("path") || "";
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path,
|
||||
},
|
||||
}
|
||||
);
|
||||
setEntries(response.data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path,
|
||||
},
|
||||
},
|
||||
);
|
||||
setEntries(response.data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
// Listen for refresh events
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => void loadFiles();
|
||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||
}, [loadFiles]);
|
||||
// Listen for refresh events
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => void loadFiles();
|
||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("path", entry.path);
|
||||
setSearchParams(newParams);
|
||||
} else {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("file", entry.path);
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
};
|
||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("path", entry.path);
|
||||
setSearchParams(newParams);
|
||||
} else {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("file", entry.path);
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!path) return;
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (parentPath) {
|
||||
newParams.set("path", parentPath);
|
||||
} else {
|
||||
newParams.delete("path");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
const navigateUp = () => {
|
||||
if (!path) return;
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (parentPath) {
|
||||
newParams.set("path", parentPath);
|
||||
} else {
|
||||
newParams.delete("path");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const getFileStatus = (filePath: string): string | null => {
|
||||
if (!gitStatus) return null;
|
||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||
if (gitStatus.added.includes(filePath)) return "added";
|
||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||
return null;
|
||||
};
|
||||
const getFileStatus = (filePath: string): string | null => {
|
||||
if (!gitStatus) return null;
|
||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||
if (gitStatus.added.includes(filePath)) return "added";
|
||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
if (loading) return <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
|
||||
<Icon name="folder" size="sm" /> ..
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
|
||||
{fileStatus && (
|
||||
<span className={`file-status-indicator ${fileStatus}`}>
|
||||
{fileStatus === "modified" && "M"}
|
||||
{fileStatus === "added" && "A"}
|
||||
{fileStatus === "deleted" && "D"}
|
||||
{fileStatus === "untracked" && "?"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button
|
||||
className="tree-entry tree-up"
|
||||
onClick={navigateUp}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="folder" size="sm" /> ..
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus =
|
||||
entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
name={entry.type === "directory" ? "folder" : "file"}
|
||||
size="sm"
|
||||
/>{" "}
|
||||
{entry.name}
|
||||
{fileStatus && (
|
||||
<span className={`file-status-indicator ${fileStatus}`}>
|
||||
{fileStatus === "modified" && "M"}
|
||||
{fileStatus === "added" && "A"}
|
||||
{fileStatus === "deleted" && "D"}
|
||||
{fileStatus === "untracked" && "?"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+617
-552
File diff suppressed because it is too large
Load Diff
+356
-320
@@ -6,350 +6,386 @@ import type { ToolType } from "../types/tool-type";
|
||||
import type { ToolConfig } from "../types/tool-config";
|
||||
import { listToolTypes } from "../api/tool_types";
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
} from "../api/tool_configs";
|
||||
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolConfigsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [typesData, configsData] = await Promise.all([
|
||||
listToolTypes(),
|
||||
listToolConfigs(),
|
||||
]);
|
||||
setToolTypes(typesData);
|
||||
setConfigs(configsData);
|
||||
if (typesData.length > 0 && !selectedToolType) {
|
||||
setSelectedToolType(typesData[0].id);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [selectedToolType]);
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [typesData, configsData] = await Promise.all([
|
||||
listToolTypes(),
|
||||
listToolConfigs(),
|
||||
]);
|
||||
setToolTypes(typesData);
|
||||
setConfigs(configsData);
|
||||
if (typesData.length > 0 && !selectedToolType) {
|
||||
setSelectedToolType(typesData[0].id);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [selectedToolType]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const data = {
|
||||
tool_type_id: selectedToolType,
|
||||
key: formData.key,
|
||||
value: formData.value,
|
||||
config_type: formData.config_type,
|
||||
file_path: formData.config_type === "file" ? formData.file_path : undefined,
|
||||
};
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const data = {
|
||||
tool_type_id: selectedToolType,
|
||||
key: formData.key,
|
||||
value: formData.value,
|
||||
config_type: formData.config_type,
|
||||
file_path:
|
||||
formData.config_type === "file" ? formData.file_path : undefined,
|
||||
};
|
||||
|
||||
if (editingConfig) {
|
||||
await updateToolConfig(editingConfig.id, data);
|
||||
} else {
|
||||
await createToolConfig(data);
|
||||
}
|
||||
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");
|
||||
}
|
||||
};
|
||||
setSaveStatus("saved");
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||
await loadData();
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (config: ToolConfig) => {
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
config_type: config.config_type,
|
||||
file_path: config.file_path || "",
|
||||
});
|
||||
setSelectedToolType(config.tool_type_id);
|
||||
setShowForm(true);
|
||||
};
|
||||
const handleEdit = (config: ToolConfig) => {
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
config_type: config.config_type,
|
||||
file_path: config.file_path || "",
|
||||
});
|
||||
setSelectedToolType(config.tool_type_id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Delete this config?")) return;
|
||||
try {
|
||||
await deleteToolConfig(id);
|
||||
await loadData();
|
||||
} catch {
|
||||
// Error handled by UI state
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Delete this config?")) return;
|
||||
try {
|
||||
await deleteToolConfig(id);
|
||||
await loadData();
|
||||
} catch {
|
||||
// Error handled by UI state
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConfigs = configs.filter(
|
||||
(c) => c.tool_type_id === selectedToolType
|
||||
);
|
||||
const filteredConfigs = configs.filter(
|
||||
(c) => c.tool_type_id === selectedToolType,
|
||||
);
|
||||
|
||||
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<p className="muted">Loading...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<p className="muted">Loading...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<div className="card stack">
|
||||
<p>Failed to load configurations</p>
|
||||
<button className="secondary-button" onClick={() => void loadData()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<div className="card stack">
|
||||
<p>Failed to load configurations</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadData()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<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>
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<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
|
||||
id="tool-type-select"
|
||||
value={selectedToolType}
|
||||
onChange={(e) => {
|
||||
setSelectedToolType(e.target.value);
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTool && (
|
||||
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Tool Type Selector */}
|
||||
<div className="card">
|
||||
<label htmlFor="tool-type-select">Select Tool</label>
|
||||
<select
|
||||
id="tool-type-select"
|
||||
value={selectedToolType}
|
||||
onChange={(e) => {
|
||||
setSelectedToolType(e.target.value);
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTool && (
|
||||
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||
Category: {selectedTool.category} · Interfaces:{" "}
|
||||
{selectedTool.interfaces?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config List */}
|
||||
<div className="card stack">
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2>Configuration Variables</h2>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingConfig(null);
|
||||
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Config
|
||||
</button>
|
||||
</div>
|
||||
{/* Config List */}
|
||||
<div className="card stack">
|
||||
<div
|
||||
className="row"
|
||||
style={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<h2>Configuration Variables</h2>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingConfig(null);
|
||||
setFormData({
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredConfigs.length === 0 ? (
|
||||
<p className="muted">No configurations for this tool yet.</p>
|
||||
) : (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{filteredConfigs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => handleEdit(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleDelete(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredConfigs.length === 0 ? (
|
||||
<p className="muted">No configurations for this tool yet.</p>
|
||||
) : (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{filteredConfigs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "center" }}
|
||||
>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background:
|
||||
config.config_type === "env"
|
||||
? "var(--color-info)"
|
||||
: "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
|
||||
>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => handleEdit(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleDelete(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card stack">
|
||||
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div>
|
||||
<label htmlFor="config-key">Key</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
placeholder="e.g., OPENAI_API_KEY"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="primary-button">
|
||||
{editingConfig ? "Update" : "Add"} Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<p className="text-success" style={{ textAlign: "right" }}>
|
||||
Saved successfully!
|
||||
</p>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<p className="text-error" style={{ textAlign: "right" }}>
|
||||
Failed to save. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{saveStatus === "saved" && (
|
||||
<p className="text-success" style={{ textAlign: "right" }}>
|
||||
Saved successfully!
|
||||
</p>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<p className="text-error" style={{ textAlign: "right" }}>
|
||||
Failed to save. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
+371
-340
@@ -1,12 +1,16 @@
|
||||
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,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
} from "../api/tool_types";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
@@ -14,365 +18,392 @@ 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 [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 navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("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 () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadToolTypes();
|
||||
}, [loadToolTypes]);
|
||||
useEffect(() => {
|
||||
void loadToolTypes();
|
||||
}, [loadToolTypes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDisplayName("");
|
||||
setFormDescription("");
|
||||
setFormCategory("");
|
||||
setFormInterfaces([]);
|
||||
setFormPort("");
|
||||
setFormTemplate("");
|
||||
setFormVariables("");
|
||||
setFormError(null);
|
||||
setEditingToolType(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
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");
|
||||
};
|
||||
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");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingToolType(null);
|
||||
setFormError(null);
|
||||
};
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingToolType(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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 (!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;
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
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 handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
setDeleteConfirmId(null);
|
||||
await loadToolTypes();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
setDeleteConfirmId(null);
|
||||
await loadToolTypes();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading tool types...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading tool types...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load tool types.</p>
|
||||
<button onClick={loadToolTypes}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load tool types.</p>
|
||||
<button onClick={loadToolTypes}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{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>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>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>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>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>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>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>
|
||||
<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>}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Session } from "../types/session";
|
||||
|
||||
export type { Session } from "../types/session";
|
||||
|
||||
interface SessionsContextType {
|
||||
sessions: Session[];
|
||||
setAllSessions: (sessions: Session[]) => void;
|
||||
sessions: Session[];
|
||||
setAllSessions: (sessions: Session[]) => void;
|
||||
}
|
||||
|
||||
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
|
||||
const SessionsContext = createContext<SessionsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
|
||||
const setAllSessions = useCallback((newSessions: Session[]) => {
|
||||
setSessions(newSessions);
|
||||
}, []);
|
||||
const setAllSessions = useCallback((newSessions: Session[]) => {
|
||||
setSessions(newSessions);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||
{children}
|
||||
</SessionsContext.Provider>
|
||||
);
|
||||
return (
|
||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||
{children}
|
||||
</SessionsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSessions = () => {
|
||||
const context = useContext(SessionsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSessions must be used within a SessionsProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(SessionsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSessions must be used within a SessionsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
export interface ConfigFolder {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
mount_path: string;
|
||||
files: Record<string, string>;
|
||||
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
mount_path: string;
|
||||
files: Record<string, string>;
|
||||
project_overrides: Record<
|
||||
string,
|
||||
{ mount_path?: string; files?: Record<string, string> }
|
||||
> | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateConfigFolderRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
mount_path: string;
|
||||
files?: Record<string, string>;
|
||||
is_active?: boolean;
|
||||
name: string;
|
||||
description?: string;
|
||||
mount_path: string;
|
||||
files?: Record<string, string>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateConfigFolderRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
mount_path?: string;
|
||||
files?: Record<string, string>;
|
||||
is_active?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
mount_path?: string;
|
||||
files?: Record<string, string>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectOverrideRequest {
|
||||
mount_path?: string;
|
||||
files?: Record<string, string>;
|
||||
mount_path?: string;
|
||||
files?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
export interface GitRepository {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
project_id: string;
|
||||
owner_id: string;
|
||||
is_mirror: boolean;
|
||||
remote_url: string | null;
|
||||
last_push: string | null;
|
||||
created_at: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
project_id: string;
|
||||
owner_id: string;
|
||||
is_mirror: boolean;
|
||||
remote_url: string | null;
|
||||
last_push: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface GitRepositoryCreate {
|
||||
name: string;
|
||||
remote_url?: string;
|
||||
force_original_url?: boolean;
|
||||
name: string;
|
||||
remote_url?: string;
|
||||
force_original_url?: boolean;
|
||||
}
|
||||
|
||||
export interface URLParseResult {
|
||||
original_url: string;
|
||||
base_url: string | null;
|
||||
is_valid_clone_url: boolean;
|
||||
needs_parsing: boolean;
|
||||
host: string | null;
|
||||
message: string;
|
||||
error_code: string | null;
|
||||
original_url: string;
|
||||
base_url: string | null;
|
||||
is_valid_clone_url: boolean;
|
||||
needs_parsing: boolean;
|
||||
host: string | null;
|
||||
message: string;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
export interface CommitHistoryEntry {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
refs: string[];
|
||||
graph_symbol: string;
|
||||
graph_depth: number;
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
refs: string[];
|
||||
graph_symbol: string;
|
||||
graph_depth: number;
|
||||
}
|
||||
|
||||
export interface CommitHistoryResponse {
|
||||
commits: CommitHistoryEntry[];
|
||||
branches: string[];
|
||||
tags: string[];
|
||||
commits: CommitHistoryEntry[];
|
||||
branches: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CommitDetail {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
committer_name: string;
|
||||
committer_email: string;
|
||||
committer_date: string;
|
||||
stats: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files_changed: number;
|
||||
};
|
||||
diff: string;
|
||||
parents: string[];
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
committer_name: string;
|
||||
committer_email: string;
|
||||
committer_date: string;
|
||||
stats: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files_changed: number;
|
||||
};
|
||||
diff: string;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
renamed: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
branch: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
renamed: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface CommitResponse {
|
||||
commit_hash: string;
|
||||
message: string;
|
||||
commit_hash: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MergeResponse {
|
||||
commit_hash: string;
|
||||
message: string;
|
||||
commit_hash: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
+19
-14
@@ -1,24 +1,29 @@
|
||||
export type { ApiResponse, PaginatedResponse } from "./api-response";
|
||||
export type { ConfigFolder, CreateConfigFolderRequest, UpdateConfigFolderRequest, ProjectOverrideRequest } from "./config-folder";
|
||||
export type {
|
||||
CommitDetail,
|
||||
CommitHistoryEntry,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
} from "./config-folder";
|
||||
export type {
|
||||
CommitDetail,
|
||||
CommitHistoryEntry,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
} from "./git-repository";
|
||||
export type { Project } from "./project";
|
||||
export type { Session } from "./session";
|
||||
export type { ToolConfig, CreateToolConfigRequest } from "./tool-config";
|
||||
export type { ToolInstance } from "./tool-instance";
|
||||
export type {
|
||||
CreateToolTypeRequest,
|
||||
ReadinessProbe,
|
||||
ToolType,
|
||||
UpdateToolTypeRequest,
|
||||
CreateToolTypeRequest,
|
||||
ReadinessProbe,
|
||||
ToolType,
|
||||
UpdateToolTypeRequest,
|
||||
} from "./tool-type";
|
||||
export type { SessionPayload, SessionUser } from "./user";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export interface Session {
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
export interface ToolConfig {
|
||||
id: string;
|
||||
tool_type_id: string;
|
||||
project_id: string | null;
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string | null;
|
||||
port_override: number | null;
|
||||
start_command: string | null;
|
||||
working_directory: string | null;
|
||||
environment_variables: Record<string, string> | null;
|
||||
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
||||
id: string;
|
||||
tool_type_id: string;
|
||||
project_id: string | null;
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string | null;
|
||||
port_override: number | null;
|
||||
start_command: string | null;
|
||||
working_directory: string | null;
|
||||
environment_variables: Record<string, string> | null;
|
||||
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
||||
}
|
||||
|
||||
export interface CreateToolConfigRequest {
|
||||
tool_type_id: string;
|
||||
project_id?: string;
|
||||
key: string;
|
||||
value: string;
|
||||
config_type?: string;
|
||||
file_path?: string;
|
||||
port_override?: number;
|
||||
start_command?: string;
|
||||
working_directory?: string;
|
||||
environment_variables?: Record<string, string>;
|
||||
volumes?: Array<{ source: string; target: string; type?: string }>;
|
||||
tool_type_id: string;
|
||||
project_id?: string;
|
||||
key: string;
|
||||
value: string;
|
||||
config_type?: string;
|
||||
file_path?: string;
|
||||
port_override?: number;
|
||||
start_command?: string;
|
||||
working_directory?: string;
|
||||
environment_variables?: Record<string, string>;
|
||||
volumes?: Array<{ source: string; target: string; type?: string }>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export interface ToolInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tool_type_id: string;
|
||||
tool_type_name: string;
|
||||
tool_type_interfaces: string[];
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tool_type_id: string;
|
||||
tool_type_name: string;
|
||||
tool_type_interfaces: string[];
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
export interface ReadinessProbe {
|
||||
command: string;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
command: string;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface ToolType {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
interfaces: string[];
|
||||
default_port: number | null;
|
||||
definition_type: "compose" | "dockerfile";
|
||||
compose_template: string | null;
|
||||
dockerfile_template: string | null;
|
||||
build_context: Record<string, string> | null;
|
||||
readiness_probe: ReadinessProbe | null;
|
||||
required_variables: string[];
|
||||
is_builtin: boolean;
|
||||
created_by_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
interfaces: string[];
|
||||
default_port: number | null;
|
||||
definition_type: "compose" | "dockerfile";
|
||||
compose_template: string | null;
|
||||
dockerfile_template: string | null;
|
||||
build_context: Record<string, string> | null;
|
||||
readiness_probe: ReadinessProbe | null;
|
||||
required_variables: string[];
|
||||
is_builtin: boolean;
|
||||
created_by_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateToolTypeRequest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port: number;
|
||||
definition_type?: "compose" | "dockerfile";
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables: string[];
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port: number;
|
||||
definition_type?: "compose" | "dockerfile";
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables: string[];
|
||||
}
|
||||
|
||||
export interface UpdateToolTypeRequest {
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port?: number;
|
||||
definition_type?: "compose" | "dockerfile";
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables?: string[];
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port?: number;
|
||||
definition_type?: "compose" | "dockerfile";
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables?: string[];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
user: SessionUser;
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user