fix: add from __future__ import annotations to workspace_manager.py
Fixes NameError: ToolInstance not defined at runtime because type annotations are evaluated at class definition time. Deferring annotation evaluation with __future__ annotations keeps TYPE_CHECKING imports from causing runtime crashes. Also includes ruff formatting cleanup on workspace-related files.
This commit is contained in:
@@ -1684,6 +1684,7 @@ async def start_instance(
|
|||||||
repo_path = ""
|
repo_path = ""
|
||||||
if instance.workspace_id:
|
if instance.workspace_id:
|
||||||
from src.models.workspace import Workspace as WorkspaceModel
|
from src.models.workspace import Workspace as WorkspaceModel
|
||||||
|
|
||||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||||
if workspace:
|
if workspace:
|
||||||
repo_path = workspace.path
|
repo_path = workspace.path
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ async def create_workspace(
|
|||||||
"branch": workspace.branch,
|
"branch": workspace.branch,
|
||||||
"path": workspace.path,
|
"path": workspace.path,
|
||||||
"status": workspace.status,
|
"status": workspace.status,
|
||||||
"created_at": workspace.created_at.isoformat() if workspace.created_at else None,
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -216,9 +218,7 @@ async def delete_workspace(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
detail={
|
detail={
|
||||||
"message": "Workspace has running tool instances",
|
"message": "Workspace has running tool instances",
|
||||||
"instances": [
|
"instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
|
||||||
{"id": str(i.id), "name": i.name} for i in exc.instances
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Workspace lifecycle management service."""
|
"""Workspace lifecycle management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -170,6 +172,4 @@ class WorkspaceManager:
|
|||||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||||
For now, this is a placeholder.
|
For now, this is a placeholder.
|
||||||
"""
|
"""
|
||||||
logger.warning(
|
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||||
"Placeholder: stopping and deleting instance %s", instance.id
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
|||||||
class TestListWorkspaces:
|
class TestListWorkspaces:
|
||||||
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_list_empty(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Returns empty list when no workspaces exist."""
|
"""Returns empty list when no workspaces exist."""
|
||||||
response = authenticated_client.get(
|
response = authenticated_client.get(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||||
@@ -69,7 +71,10 @@ class TestListWorkspaces:
|
|||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
def test_list_with_workspaces(
|
def test_list_with_workspaces(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns workspaces with instance counts."""
|
"""Returns workspaces with instance counts."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -99,7 +104,9 @@ class TestListWorkspaces:
|
|||||||
class TestCreateWorkspace:
|
class TestCreateWorkspace:
|
||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_create_success(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Creates a workspace and clones the repo."""
|
"""Creates a workspace and clones the repo."""
|
||||||
mock_ws = Workspace(
|
mock_ws = Workspace(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -110,7 +117,9 @@ class TestCreateWorkspace:
|
|||||||
path="/data/working-copies/test/feature-branch",
|
path="/data/working-copies/test/feature-branch",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create:
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", return_value=mock_ws
|
||||||
|
) as mock_create:
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
json={"name": "feature-branch", "branch": "feature"},
|
json={"name": "feature-branch", "branch": "feature"},
|
||||||
@@ -121,7 +130,9 @@ class TestCreateWorkspace:
|
|||||||
assert data["branch"] == "feature"
|
assert data["branch"] == "feature"
|
||||||
mock_create.assert_called_once()
|
mock_create.assert_called_once()
|
||||||
|
|
||||||
def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_create_missing_name(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Returns 400 when name is missing."""
|
"""Returns 400 when name is missing."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
@@ -131,7 +142,10 @@ class TestCreateWorkspace:
|
|||||||
assert "name" in response.json()["detail"]
|
assert "name" in response.json()["detail"]
|
||||||
|
|
||||||
def test_create_duplicate_name(
|
def test_create_duplicate_name(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when workspace name already exists."""
|
"""Returns 409 when workspace name already exists."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -148,7 +162,9 @@ class TestCreateWorkspace:
|
|||||||
|
|
||||||
asyncio.run(_commit())
|
asyncio.run(_commit())
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")):
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", side_effect=Exception("duplicate")
|
||||||
|
):
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
json={"name": "dev", "branch": "main"},
|
json={"name": "dev", "branch": "main"},
|
||||||
@@ -160,7 +176,10 @@ class TestDeleteWorkspace:
|
|||||||
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
||||||
|
|
||||||
def test_delete_without_instances(
|
def test_delete_without_instances(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Deletes workspace when no instances exist."""
|
"""Deletes workspace when no instances exist."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -185,9 +204,14 @@ class TestDeleteWorkspace:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "deleted"
|
assert response.json()["status"] == "deleted"
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually")
|
@pytest.mark.skip(
|
||||||
|
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
|
||||||
|
)
|
||||||
def test_delete_with_instances_no_force(
|
def test_delete_with_instances_no_force(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when workspace has instances and force=False."""
|
"""Returns 409 when workspace has instances and force=False."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -239,7 +263,10 @@ class TestDeleteWorkspace:
|
|||||||
assert len(detail["instances"]) == 1
|
assert len(detail["instances"]) == 1
|
||||||
|
|
||||||
def test_delete_with_instances_force(
|
def test_delete_with_instances_force(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Deletes workspace when force=True even with instances."""
|
"""Deletes workspace when force=True even with instances."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -268,7 +295,10 @@ class TestSyncWorkspace:
|
|||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
||||||
|
|
||||||
def test_sync_success(
|
def test_sync_success(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Sync succeeds and updates last_sync_at."""
|
"""Sync succeeds and updates last_sync_at."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -298,7 +328,10 @@ class TestSyncWorkspace:
|
|||||||
assert data["pulled"] is True
|
assert data["pulled"] is True
|
||||||
|
|
||||||
def test_sync_branch_deleted(
|
def test_sync_branch_deleted(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when branch was deleted from remote."""
|
"""Returns 409 when branch was deleted from remote."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ class TestGitServiceClone:
|
|||||||
with patch(
|
with patch(
|
||||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||||
) as mock_exec:
|
) as mock_exec:
|
||||||
await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws")
|
await GitService.clone(
|
||||||
|
"https://github.com/test/repo.git", "main", "/tmp/ws"
|
||||||
|
)
|
||||||
|
|
||||||
mock_exec.assert_called_once_with(
|
mock_exec.assert_called_once_with(
|
||||||
"git",
|
"git",
|
||||||
|
|||||||
+154
-140
@@ -2,185 +2,199 @@ import { AxiosError } from "axios";
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
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;
|
||||||
selected_config_profile_id: string | null;
|
selected_config_profile_id: string | null;
|
||||||
ssh_key_ids: string[];
|
ssh_key_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
container_status?: string;
|
container_status?: string;
|
||||||
probe_status?: string;
|
probe_status?: string;
|
||||||
clone_mode?: string;
|
clone_mode?: string;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
workspaceId?: string
|
workspaceId?: 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,
|
||||||
workspace_id: workspaceId || undefined,
|
workspace_id: workspaceId || undefined,
|
||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
config_profile_id: configProfileId,
|
config_profile_id: configProfileId,
|
||||||
ssh_key_ids: sshKeyIds || [],
|
ssh_key_ids: sshKeyIds || [],
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
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,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
return startInstance(
|
||||||
}
|
projectId,
|
||||||
throw error;
|
repoId,
|
||||||
}
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
return restartInstance(
|
||||||
}
|
projectId,
|
||||||
throw error;
|
repoId,
|
||||||
}
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
force?: boolean
|
force?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
{ params: { force } }
|
{ params: { force } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 interface InstanceHealth {
|
export interface InstanceHealth {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
container_status: string;
|
container_status: string;
|
||||||
container_health: string | null;
|
container_health: string | null;
|
||||||
container_exit_code: number | null;
|
container_exit_code: number | null;
|
||||||
tunnel_status: string;
|
tunnel_status: string;
|
||||||
tunnel_status_code: number | null;
|
tunnel_status_code: number | null;
|
||||||
probe_status: string;
|
probe_status: string;
|
||||||
last_probe_output: string | null;
|
last_probe_output: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<InstanceHealth> {
|
): Promise<InstanceHealth> {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,82 @@
|
|||||||
/** Workspace API client. */
|
/** Workspace API client. */
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
|
import type {
|
||||||
|
Workspace,
|
||||||
|
CreateWorkspaceRequest,
|
||||||
|
SyncResult,
|
||||||
|
} from "../types/workspace";
|
||||||
|
|
||||||
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
||||||
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||||
return workspaceId ? `${base}/${workspaceId}` : base;
|
return workspaceId ? `${base}/${workspaceId}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> {
|
export async function listWorkspaces(
|
||||||
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
|
projectId: string,
|
||||||
return response.data;
|
repoId: string,
|
||||||
|
): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>(
|
||||||
|
workspaceUrl(projectId, repoId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWorkspace(
|
export async function createWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
data: CreateWorkspaceRequest,
|
data: CreateWorkspaceRequest,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
|
const response = await apiClient.post<Workspace>(
|
||||||
return response.data;
|
workspaceUrl(projectId, repoId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkspace(
|
export async function getWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
|
const response = await apiClient.get<Workspace>(
|
||||||
return response.data;
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkspace(
|
export async function updateWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: Partial<CreateWorkspaceRequest>,
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data);
|
const response = await apiClient.patch<Workspace>(
|
||||||
return response.data;
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWorkspace(
|
export async function deleteWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
force = false,
|
force = false,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const response = await apiClient.delete<{ status: string }>(
|
const response = await apiClient.delete<{ status: string }>(
|
||||||
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
|
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncWorkspace(
|
export async function syncWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<SyncResult> {
|
): Promise<SyncResult> {
|
||||||
const response = await apiClient.post<SyncResult>(
|
const response = await apiClient.post<SyncResult>(
|
||||||
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,83 +5,96 @@ import { Icon } from "./icon";
|
|||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
export interface StartToolModalProps {
|
export interface StartToolModalProps {
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
|
export function StartToolModal({
|
||||||
const [toolTypeId, setToolTypeId] = useState("");
|
workspace,
|
||||||
const [configProfileId, setConfigProfileId] = useState("");
|
onClose,
|
||||||
const [submitting, setSubmitting] = useState(false);
|
onStart,
|
||||||
const [error, setError] = useState<string | null>(null);
|
}: StartToolModalProps) {
|
||||||
|
const [toolTypeId, setToolTypeId] = useState("");
|
||||||
|
const [configProfileId, setConfigProfileId] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!toolTypeId) {
|
if (!toolTypeId) {
|
||||||
setError("Please select a tool type");
|
setError("Please select a tool type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onStart(toolTypeId, configProfileId || undefined);
|
await onStart(toolTypeId, configProfileId || undefined);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>
|
<h3>
|
||||||
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||||
</h3>
|
</h3>
|
||||||
<button className="btn btn-icon" onClick={onClose}>
|
<button className="btn btn-icon" onClick={onClose}>
|
||||||
<Icon name="cancel" size="sm" />
|
<Icon name="cancel" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="tool-type">Tool Type</label>
|
<label htmlFor="tool-type">Tool Type</label>
|
||||||
<select
|
<select
|
||||||
id="tool-type"
|
id="tool-type"
|
||||||
value={toolTypeId}
|
value={toolTypeId}
|
||||||
onChange={(e) => setToolTypeId(e.target.value)}
|
onChange={(e) => setToolTypeId(e.target.value)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<option value="">Select a tool...</option>
|
<option value="">Select a tool...</option>
|
||||||
<option value="code-server">Code Server</option>
|
<option value="code-server">Code Server</option>
|
||||||
<option value="jupyter-notebook">Jupyter Notebook</option>
|
<option value="jupyter-notebook">Jupyter Notebook</option>
|
||||||
<option value="terminal">Terminal</option>
|
<option value="terminal">Terminal</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="config-profile">Config Profile (optional)</label>
|
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||||
<input
|
<input
|
||||||
id="config-profile"
|
id="config-profile"
|
||||||
type="text"
|
type="text"
|
||||||
value={configProfileId}
|
value={configProfileId}
|
||||||
onChange={(e) => setConfigProfileId(e.target.value)}
|
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||||
placeholder="Profile ID"
|
placeholder="Profile ID"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
<button
|
||||||
Cancel
|
type="button"
|
||||||
</button>
|
className="btn btn-secondary"
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
onClick={onClose}
|
||||||
{submitting ? "Starting..." : "Start Tool"}
|
disabled={submitting}
|
||||||
</button>
|
>
|
||||||
</div>
|
Cancel
|
||||||
</form>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="submit"
|
||||||
);
|
className="btn btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Starting..." : "Start Tool"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,70 +4,72 @@ import { Icon } from "./icon";
|
|||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
export interface WorkspaceCardProps {
|
export interface WorkspaceCardProps {
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onStartTool: (workspace: Workspace) => void;
|
onStartTool: (workspace: Workspace) => void;
|
||||||
onSync: (workspace: Workspace) => void;
|
onSync: (workspace: Workspace) => void;
|
||||||
onDelete: (workspace: Workspace) => void;
|
onDelete: (workspace: Workspace) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceCard({
|
export function WorkspaceCard({
|
||||||
workspace,
|
workspace,
|
||||||
loading = false,
|
loading = false,
|
||||||
onStartTool,
|
onStartTool,
|
||||||
onSync,
|
onSync,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: WorkspaceCardProps) {
|
}: WorkspaceCardProps) {
|
||||||
const statusClass =
|
const statusClass =
|
||||||
workspace.status === "ready"
|
workspace.status === "ready"
|
||||||
? "status-ready"
|
? "status-ready"
|
||||||
: workspace.status === "syncing"
|
: workspace.status === "syncing"
|
||||||
? "status-syncing"
|
? "status-syncing"
|
||||||
: "status-error";
|
: "status-error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||||
<div className="workspace-header">
|
<div className="workspace-header">
|
||||||
<h4>{workspace.name}</h4>
|
<h4>{workspace.name}</h4>
|
||||||
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
|
<span className={`status-badge ${statusClass}`}>
|
||||||
</div>
|
{workspace.status}
|
||||||
<div className="workspace-meta">
|
</span>
|
||||||
<p className="workspace-project">
|
</div>
|
||||||
{workspace.project_name} / {workspace.repo_name}
|
<div className="workspace-meta">
|
||||||
</p>
|
<p className="workspace-project">
|
||||||
<p className="workspace-branch">
|
{workspace.project_name} / {workspace.repo_name}
|
||||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
</p>
|
||||||
</p>
|
<p className="workspace-branch">
|
||||||
{workspace.instance_count > 0 && (
|
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||||
<p className="workspace-instances">
|
</p>
|
||||||
{workspace.instance_count} active tool
|
{workspace.instance_count > 0 && (
|
||||||
{workspace.instance_count > 1 ? "s" : ""}
|
<p className="workspace-instances">
|
||||||
</p>
|
{workspace.instance_count} active tool
|
||||||
)}
|
{workspace.instance_count > 1 ? "s" : ""}
|
||||||
</div>
|
</p>
|
||||||
<div className="workspace-actions">
|
)}
|
||||||
<button
|
</div>
|
||||||
className="btn btn-primary"
|
<div className="workspace-actions">
|
||||||
onClick={() => onStartTool(workspace)}
|
<button
|
||||||
disabled={loading}
|
className="btn btn-primary"
|
||||||
>
|
onClick={() => onStartTool(workspace)}
|
||||||
<Icon name="play" size="sm" /> Start Tool
|
disabled={loading}
|
||||||
</button>
|
>
|
||||||
<button
|
<Icon name="play" size="sm" /> Start Tool
|
||||||
className="btn btn-secondary"
|
</button>
|
||||||
onClick={() => onSync(workspace)}
|
<button
|
||||||
disabled={loading}
|
className="btn btn-secondary"
|
||||||
>
|
onClick={() => onSync(workspace)}
|
||||||
<Icon name="refresh" size="sm" /> Sync
|
disabled={loading}
|
||||||
</button>
|
>
|
||||||
<button
|
<Icon name="refresh" size="sm" /> Sync
|
||||||
className="btn btn-danger"
|
</button>
|
||||||
onClick={() => onDelete(workspace)}
|
<button
|
||||||
disabled={loading}
|
className="btn btn-danger"
|
||||||
>
|
onClick={() => onDelete(workspace)}
|
||||||
<Icon name="delete" size="sm" /> Delete
|
disabled={loading}
|
||||||
</button>
|
>
|
||||||
</div>
|
<Icon name="delete" size="sm" /> Delete
|
||||||
</article>
|
</button>
|
||||||
);
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,78 +5,85 @@ import { Icon } from "./icon";
|
|||||||
import type { CreateWorkspaceRequest } from "../types/workspace";
|
import type { CreateWorkspaceRequest } from "../types/workspace";
|
||||||
|
|
||||||
export interface WorkspaceCreateFormProps {
|
export interface WorkspaceCreateFormProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
defaultBranch?: string;
|
defaultBranch?: string;
|
||||||
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
|
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceCreateForm({
|
export function WorkspaceCreateForm({
|
||||||
defaultBranch = "main",
|
defaultBranch = "main",
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WorkspaceCreateFormProps) {
|
}: WorkspaceCreateFormProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [branch, setBranch] = useState(defaultBranch);
|
const [branch, setBranch] = useState(defaultBranch);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Workspace name is required");
|
setError("Workspace name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create workspace");
|
setError(
|
||||||
} finally {
|
err instanceof Error ? err.message : "Failed to create workspace",
|
||||||
setSubmitting(false);
|
);
|
||||||
}
|
} finally {
|
||||||
};
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="workspace-create-form card" onSubmit={handleSubmit}>
|
<form className="workspace-create-form card" onSubmit={handleSubmit}>
|
||||||
<h3>
|
<h3>
|
||||||
<Icon name="add" size="sm" /> Create Workspace
|
<Icon name="add" size="sm" /> Create Workspace
|
||||||
</h3>
|
</h3>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="ws-name">Name</label>
|
<label htmlFor="ws-name">Name</label>
|
||||||
<input
|
<input
|
||||||
id="ws-name"
|
id="ws-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., feature-branch"
|
placeholder="e.g., feature-branch"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="ws-branch">
|
<label htmlFor="ws-branch">
|
||||||
<Icon name="branch" size="sm" /> Branch
|
<Icon name="branch" size="sm" /> Branch
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="ws-branch"
|
id="ws-branch"
|
||||||
type="text"
|
type="text"
|
||||||
value={branch}
|
value={branch}
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
placeholder="main"
|
placeholder="main"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
|
<button
|
||||||
Cancel
|
type="button"
|
||||||
</button>
|
className="btn btn-secondary"
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
onClick={onCancel}
|
||||||
{submitting ? "Creating..." : "Create"}
|
disabled={submitting}
|
||||||
</button>
|
>
|
||||||
</div>
|
Cancel
|
||||||
</form>
|
</button>
|
||||||
);
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,145 +2,152 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
syncWorkspace,
|
syncWorkspace,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
} from "../api/workspaces";
|
} from "../api/workspaces";
|
||||||
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
|
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
|
||||||
|
|
||||||
export interface UseWorkspaceActionsResult {
|
export interface UseWorkspaceActionsResult {
|
||||||
loadingId: string | null;
|
loadingId: string | null;
|
||||||
create: (
|
create: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
data: CreateWorkspaceRequest,
|
data: CreateWorkspaceRequest,
|
||||||
) => Promise<Workspace>;
|
) => Promise<Workspace>;
|
||||||
delete: (
|
delete: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
onRefresh: () => Promise<void>,
|
onRefresh: () => Promise<void>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sync: (
|
sync: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
onRefresh: () => Promise<void>,
|
onRefresh: () => Promise<void>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
update: (
|
update: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: Partial<CreateWorkspaceRequest>,
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
) => Promise<Workspace>;
|
) => Promise<Workspace>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
response?: {
|
response?: {
|
||||||
status?: number;
|
status?: number;
|
||||||
data?: {
|
data?: {
|
||||||
detail?: {
|
detail?: {
|
||||||
message?: string;
|
message?: string;
|
||||||
instances?: Array<{ id: string; name: string }>;
|
instances?: Array<{ id: string; name: string }>;
|
||||||
branch_deleted?: boolean;
|
branch_deleted?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceActions(): UseWorkspaceActionsResult {
|
export function useWorkspaceActions(): UseWorkspaceActionsResult {
|
||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const create = useCallback(
|
const create = useCallback(
|
||||||
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
|
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
|
||||||
return createWorkspace(projectId, repoId, data);
|
return createWorkspace(projectId, repoId, data);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAction = useCallback(
|
const deleteAction = useCallback(
|
||||||
async (
|
async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
onRefresh: () => Promise<void>,
|
onRefresh: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
setLoadingId(workspace.id);
|
setLoadingId(workspace.id);
|
||||||
try {
|
try {
|
||||||
await deleteWorkspace(projectId, repoId, workspace.id);
|
await deleteWorkspace(projectId, repoId, workspace.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as ApiError;
|
const error = err as ApiError;
|
||||||
if (error.response?.status === 409) {
|
if (error.response?.status === 409) {
|
||||||
const detail = error.response.data?.detail;
|
const detail = error.response.data?.detail;
|
||||||
const instances = detail?.instances || [];
|
const instances = detail?.instances || [];
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`This workspace has ${instances.length} running tool instance(s):\n` +
|
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||||
instances.map((i) => `- ${i.name}`).join("\n") +
|
instances.map((i) => `- ${i.name}`).join("\n") +
|
||||||
`\n\nDelete workspace and all instances?`,
|
`\n\nDelete workspace and all instances?`,
|
||||||
);
|
);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingId(null);
|
setLoadingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sync = useCallback(
|
const sync = useCallback(
|
||||||
async (
|
async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
onRefresh: () => Promise<void>,
|
onRefresh: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
setLoadingId(workspace.id);
|
setLoadingId(workspace.id);
|
||||||
try {
|
try {
|
||||||
await syncWorkspace(projectId, repoId, workspace.id);
|
await syncWorkspace(projectId, repoId, workspace.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as ApiError;
|
const error = err as ApiError;
|
||||||
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
if (
|
||||||
const message = error.response.data.detail.message || "Branch was deleted from remote";
|
error.response?.status === 409 &&
|
||||||
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
|
error.response.data?.detail?.branch_deleted
|
||||||
if (confirmed) {
|
) {
|
||||||
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
const message =
|
||||||
await onRefresh();
|
error.response.data.detail.message ||
|
||||||
}
|
"Branch was deleted from remote";
|
||||||
} else {
|
const confirmed = window.confirm(
|
||||||
throw err;
|
`${message}\n\nDelete this workspace?`,
|
||||||
}
|
);
|
||||||
} finally {
|
if (confirmed) {
|
||||||
setLoadingId(null);
|
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||||
}
|
await onRefresh();
|
||||||
},
|
}
|
||||||
[],
|
} else {
|
||||||
);
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
async (
|
async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: Partial<CreateWorkspaceRequest>,
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
) => {
|
) => {
|
||||||
return updateWorkspace(projectId, repoId, workspaceId, data);
|
return updateWorkspace(projectId, repoId, workspaceId, data);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadingId,
|
loadingId,
|
||||||
create,
|
create,
|
||||||
delete: deleteAction,
|
delete: deleteAction,
|
||||||
sync,
|
sync,
|
||||||
update,
|
update,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,38 @@ import { listWorkspaces } from "../api/workspaces";
|
|||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
export interface UseWorkspacesResult {
|
export interface UseWorkspacesResult {
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
|
export function useWorkspaces(
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
projectId: string,
|
||||||
const [loading, setLoading] = useState(true);
|
repoId: string,
|
||||||
const [error, setError] = useState<string | null>(null);
|
): UseWorkspacesResult {
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await listWorkspaces(projectId, repoId);
|
const data = await listWorkspaces(projectId, repoId);
|
||||||
setWorkspaces(data);
|
setWorkspaces(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load workspaces");
|
setError(
|
||||||
} finally {
|
err instanceof Error ? err.message : "Failed to load workspaces",
|
||||||
setLoading(false);
|
);
|
||||||
}
|
} finally {
|
||||||
}, [projectId, repoId]);
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
return { workspaces, loading, error, refresh };
|
return { workspaces, loading, error, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,109 +11,125 @@ import { createInstance, startInstance } from "../api/sessions";
|
|||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
export function WorkspacesPage() {
|
export function WorkspacesPage() {
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||||
|
|
||||||
// TODO: Get projectId and repoId from URL params or context
|
// TODO: Get projectId and repoId from URL params or context
|
||||||
const projectId = "default-project";
|
const projectId = "default-project";
|
||||||
const repoId = "default-repo";
|
const repoId = "default-repo";
|
||||||
|
|
||||||
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
|
const { workspaces, loading, error, refresh } = useWorkspaces(
|
||||||
const actions = useWorkspaceActions();
|
projectId,
|
||||||
|
repoId,
|
||||||
|
);
|
||||||
|
const actions = useWorkspaceActions();
|
||||||
|
|
||||||
const handleCreate = async (data: { name: string; branch: string }) => {
|
const handleCreate = async (data: { name: string; branch: string }) => {
|
||||||
await actions.create(projectId, repoId, data);
|
await actions.create(projectId, repoId, data);
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
await refresh();
|
await refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (workspace: Workspace) => {
|
const handleDelete = async (workspace: Workspace) => {
|
||||||
await actions.delete(projectId, repoId, workspace, refresh);
|
await actions.delete(projectId, repoId, workspace, refresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async (workspace: Workspace) => {
|
const handleSync = async (workspace: Workspace) => {
|
||||||
await actions.sync(projectId, repoId, workspace, refresh);
|
await actions.sync(projectId, repoId, workspace, refresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
|
const handleStartTool = async (
|
||||||
if (!startWorkspace) return;
|
toolTypeId: string,
|
||||||
try {
|
configProfileId?: string,
|
||||||
const instance = await createInstance(
|
) => {
|
||||||
projectId,
|
if (!startWorkspace) return;
|
||||||
repoId,
|
try {
|
||||||
toolTypeId,
|
const instance = await createInstance(
|
||||||
`${startWorkspace.name} - ${toolTypeId}`,
|
projectId,
|
||||||
undefined,
|
repoId,
|
||||||
undefined,
|
toolTypeId,
|
||||||
undefined,
|
`${startWorkspace.name} - ${toolTypeId}`,
|
||||||
configProfileId,
|
undefined,
|
||||||
[],
|
undefined,
|
||||||
startWorkspace.id
|
undefined,
|
||||||
);
|
configProfileId,
|
||||||
await startInstance(projectId, repoId, instance.id, configProfileId);
|
[],
|
||||||
setStartWorkspace(null);
|
startWorkspace.id,
|
||||||
await refresh();
|
);
|
||||||
} catch (err) {
|
await startInstance(projectId, repoId, instance.id, configProfileId);
|
||||||
alert(err instanceof Error ? err.message : "Failed to start tool");
|
setStartWorkspace(null);
|
||||||
}
|
await refresh();
|
||||||
};
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to start tool");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page workspaces-page">
|
<div className="page workspaces-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1>Workspaces</h1>
|
<h1>Workspaces</h1>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
|
<button
|
||||||
<Icon name="refresh" size="sm" />
|
className="btn btn-secondary"
|
||||||
</button>
|
onClick={refresh}
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
disabled={loading}
|
||||||
<Icon name="add" size="sm" /> New Workspace
|
>
|
||||||
</button>
|
<Icon name="refresh" size="sm" />
|
||||||
</div>
|
</button>
|
||||||
</header>
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<WorkspaceCreateForm
|
<WorkspaceCreateForm
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
repoId={repoId}
|
repoId={repoId}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
onCancel={() => setShowCreate(false)}
|
onCancel={() => setShowCreate(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && workspaces.length === 0 ? (
|
{loading && workspaces.length === 0 ? (
|
||||||
<div className="loading-state">Loading workspaces...</div>
|
<div className="loading-state">Loading workspaces...</div>
|
||||||
) : workspaces.length === 0 ? (
|
) : workspaces.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No workspaces yet.</p>
|
<p>No workspaces yet.</p>
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
<button
|
||||||
<Icon name="add" size="sm" /> Create your first workspace
|
className="btn btn-primary"
|
||||||
</button>
|
onClick={() => setShowCreate(true)}
|
||||||
</div>
|
>
|
||||||
) : (
|
<Icon name="add" size="sm" /> Create your first workspace
|
||||||
<div className="workspaces-grid">
|
</button>
|
||||||
{workspaces.map((ws) => (
|
</div>
|
||||||
<WorkspaceCard
|
) : (
|
||||||
key={ws.id}
|
<div className="workspaces-grid">
|
||||||
workspace={ws}
|
{workspaces.map((ws) => (
|
||||||
loading={actions.loadingId === ws.id}
|
<WorkspaceCard
|
||||||
onStartTool={setStartWorkspace}
|
key={ws.id}
|
||||||
onSync={handleSync}
|
workspace={ws}
|
||||||
onDelete={handleDelete}
|
loading={actions.loadingId === ws.id}
|
||||||
/>
|
onStartTool={setStartWorkspace}
|
||||||
))}
|
onSync={handleSync}
|
||||||
</div>
|
onDelete={handleDelete}
|
||||||
)}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{startWorkspace && (
|
{startWorkspace && (
|
||||||
<StartToolModal
|
<StartToolModal
|
||||||
workspace={startWorkspace}
|
workspace={startWorkspace}
|
||||||
onClose={() => setStartWorkspace(null)}
|
onClose={() => setStartWorkspace(null)}
|
||||||
onStart={handleStartTool}
|
onStart={handleStartTool}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-35
@@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions";
|
|||||||
import { WorkspacesPage } from "./pages/workspaces";
|
import { WorkspacesPage } from "./pages/workspaces";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<Route path="/login" element={<LoginRedirectPage />} />
|
||||||
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
<Route
|
||||||
<Route
|
path="/ssh-keys"
|
||||||
path="/"
|
element={<Navigate to="/settings/ssh-keys" replace />}
|
||||||
element={
|
/>
|
||||||
<ProtectedRoute>
|
<Route
|
||||||
<AppShell />
|
path="/"
|
||||||
</ProtectedRoute>
|
element={
|
||||||
}
|
<ProtectedRoute>
|
||||||
>
|
<AppShell />
|
||||||
<Route index element={<HomePage />} />
|
</ProtectedRoute>
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
}
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
>
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route
|
||||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
path="projects/:projectId/repositories"
|
||||||
<Route path="settings" element={<SettingsPage />}>
|
element={<GitRepositoriesPage />}
|
||||||
<Route index element={<Navigate to="general" replace />} />
|
/>
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
path="projects/:projectId/repositories/:repoId/history"
|
||||||
<Route path="*" element={<Navigate to="general" replace />} />
|
element={<GitHistoryPage />}
|
||||||
</Route>
|
/>
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route
|
||||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
path="projects/:projectId/settings/*"
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
element={<ProjectSettingsPage />}
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
/>
|
||||||
</Route>
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
</Routes>
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
);
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
|
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||||
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
|
<Route
|
||||||
|
path="instances/:instanceId/terminal"
|
||||||
|
element={<TerminalPage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
/** Types for the workspace feature. */
|
/** Types for the workspace feature. */
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
repo_id: string;
|
repo_id: string;
|
||||||
repo_name: string;
|
repo_name: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
path: string;
|
path: string;
|
||||||
status: "ready" | "syncing" | "error";
|
status: "ready" | "syncing" | "error";
|
||||||
last_sync_at: string | null;
|
last_sync_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
instance_count: number;
|
instance_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWorkspaceRequest {
|
export interface CreateWorkspaceRequest {
|
||||||
name: string;
|
name: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
branch_deleted: boolean;
|
branch_deleted: boolean;
|
||||||
pulled: boolean;
|
pulled: boolean;
|
||||||
last_sync_at: string | null;
|
last_sync_at: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user