fix: add top-level GET /workspaces endpoint and derive project/repo from workspace data
- Add all_workspaces_router with GET /workspaces/ (no project/repo required) - Include project_id in workspace responses - Frontend: useWorkspaces() calls listAllWorkspaces when no args - Frontend: WorkspacesPage uses top-level list, derives project/repo from workspace for mutations - Fixes 422 from invalid UUID path params
This commit is contained in:
@@ -17,6 +17,54 @@ from src.services.workspace_manager import WorkspaceHasInstancesError, Workspace
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||||
|
all_workspaces_router = APIRouter(prefix="/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.get("/")
|
||||||
|
async def list_all_workspaces(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List all workspaces for the current user across all repos."""
|
||||||
|
instance_count = (
|
||||||
|
select(func.count(ToolInstance.id))
|
||||||
|
.where(ToolInstance.workspace_id == Workspace.id)
|
||||||
|
.correlate(Workspace)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Workspace,
|
||||||
|
GitRepository.name.label("repo_name"),
|
||||||
|
GitRepository.project_id,
|
||||||
|
instance_count.label("instance_count"),
|
||||||
|
)
|
||||||
|
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
||||||
|
.where(Workspace.user_id == user_id)
|
||||||
|
.order_by(Workspace.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"repo_id": str(ws.repo_id),
|
||||||
|
"repo_name": repo_name or "",
|
||||||
|
"project_id": str(project_id) if project_id else "",
|
||||||
|
"project_name": "", # Could join with Project if needed
|
||||||
|
"user_id": str(ws.user_id),
|
||||||
|
"branch": ws.branch,
|
||||||
|
"path": ws.path,
|
||||||
|
"status": ws.status,
|
||||||
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||||
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||||
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||||
|
"instance_count": count or 0,
|
||||||
|
}
|
||||||
|
for ws, repo_name, project_id, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@@ -54,6 +102,7 @@ async def list_workspaces(
|
|||||||
"name": ws.name,
|
"name": ws.name,
|
||||||
"repo_id": str(ws.repo_id),
|
"repo_id": str(ws.repo_id),
|
||||||
"repo_name": repo.name,
|
"repo_name": repo.name,
|
||||||
|
"project_id": str(repo.project_id) if repo.project_id else "",
|
||||||
"project_name": repo.project.name if repo.project else "",
|
"project_name": repo.project.name if repo.project else "",
|
||||||
"user_id": str(ws.user_id),
|
"user_id": str(ws.user_id),
|
||||||
"branch": ws.branch,
|
"branch": ws.branch,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from src.api.tool_types import router as tool_types_router
|
|||||||
from src.api.notifications import router as notifications_router
|
from src.api.notifications import router as notifications_router
|
||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.api.workspaces import router as workspaces_router
|
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||||
@@ -160,5 +160,6 @@ app.include_router(instance_proxy_router)
|
|||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
|
app.include_router(all_workspaces_router)
|
||||||
app.include_router(workspaces_router)
|
app.include_router(workspaces_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export async function listWorkspaces(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAllWorkspaces(): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>("/workspaces/");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createWorkspace(
|
export async function createWorkspace(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Hook for fetching workspaces. */
|
/** Hook for fetching workspaces. */
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { listWorkspaces } from "../api/workspaces";
|
import { listAllWorkspaces, listWorkspaces } from "../api/workspaces";
|
||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
export interface UseWorkspacesResult {
|
export interface UseWorkspacesResult {
|
||||||
@@ -12,8 +12,8 @@ export interface UseWorkspacesResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaces(
|
export function useWorkspaces(
|
||||||
projectId: string,
|
projectId?: string,
|
||||||
repoId: string,
|
repoId?: string,
|
||||||
): UseWorkspacesResult {
|
): UseWorkspacesResult {
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -23,7 +23,10 @@ export function useWorkspaces(
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await listWorkspaces(projectId, repoId);
|
const data =
|
||||||
|
projectId && repoId
|
||||||
|
? await listWorkspaces(projectId, repoId)
|
||||||
|
: await listAllWorkspaces();
|
||||||
setWorkspaces(data);
|
setWorkspaces(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -13,29 +13,25 @@ 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);
|
||||||
|
const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null);
|
||||||
|
|
||||||
// TODO: Get projectId and repoId from URL params or context
|
const { workspaces, loading, error, refresh } = useWorkspaces();
|
||||||
const projectId = "default-project";
|
|
||||||
const repoId = "default-repo";
|
|
||||||
|
|
||||||
const { workspaces, loading, error, refresh } = useWorkspaces(
|
|
||||||
projectId,
|
|
||||||
repoId,
|
|
||||||
);
|
|
||||||
const actions = useWorkspaceActions();
|
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);
|
if (!createTarget) return;
|
||||||
|
await actions.create(createTarget.projectId, createTarget.repoId, data);
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
|
setCreateTarget(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (workspace: Workspace) => {
|
const handleDelete = async (workspace: Workspace) => {
|
||||||
await actions.delete(projectId, repoId, workspace, refresh);
|
await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async (workspace: Workspace) => {
|
const handleSync = async (workspace: Workspace) => {
|
||||||
await actions.sync(projectId, repoId, workspace, refresh);
|
await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartTool = async (
|
const handleStartTool = async (
|
||||||
@@ -45,8 +41,8 @@ export function WorkspacesPage() {
|
|||||||
if (!startWorkspace) return;
|
if (!startWorkspace) return;
|
||||||
try {
|
try {
|
||||||
const instance = await createInstance(
|
const instance = await createInstance(
|
||||||
projectId,
|
startWorkspace.project_id,
|
||||||
repoId,
|
startWorkspace.repo_id,
|
||||||
toolTypeId,
|
toolTypeId,
|
||||||
`${startWorkspace.name} - ${toolTypeId}`,
|
`${startWorkspace.name} - ${toolTypeId}`,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -56,7 +52,7 @@ export function WorkspacesPage() {
|
|||||||
[],
|
[],
|
||||||
startWorkspace.id,
|
startWorkspace.id,
|
||||||
);
|
);
|
||||||
await startInstance(projectId, repoId, instance.id, configProfileId);
|
await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId);
|
||||||
setStartWorkspace(null);
|
setStartWorkspace(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -76,23 +72,31 @@ export function WorkspacesPage() {
|
|||||||
>
|
>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => {
|
||||||
>
|
if (workspaces.length > 0) {
|
||||||
<Icon name="add" size="sm" /> New Workspace
|
const first = workspaces[0];
|
||||||
</button>
|
setCreateTarget({ projectId: first.project_id, repoId: first.repo_id });
|
||||||
|
setShowCreate(true);
|
||||||
|
} else {
|
||||||
|
alert("Navigate to a project to create your first workspace.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Workspace
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && createTarget && (
|
||||||
<WorkspaceCreateForm
|
<WorkspaceCreateForm
|
||||||
projectId={projectId}
|
projectId={createTarget.projectId}
|
||||||
repoId={repoId}
|
repoId={createTarget.repoId}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
onCancel={() => setShowCreate(false)}
|
onCancel={() => { setShowCreate(false); setCreateTarget(null); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -101,12 +105,7 @@ export function WorkspacesPage() {
|
|||||||
) : 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
|
<p>Navigate to a project to create your first workspace.</p>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
>
|
|
||||||
<Icon name="add" size="sm" /> Create your first workspace
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="workspaces-grid">
|
<div className="workspaces-grid">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Workspace {
|
|||||||
name: string;
|
name: string;
|
||||||
repo_id: string;
|
repo_id: string;
|
||||||
repo_name: string;
|
repo_name: string;
|
||||||
|
project_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user