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