feat: implement tool instances backend and session navigation

Backend:
- Create ToolInstance model with status tracking
- Add Alembic migration for tool_instances table
- Create Docker service for compose template rendering and container execution
- Add CRUD API endpoints for tool instances
- Add lifecycle endpoints (start/stop/restart)
- Add user sessions endpoint for navigation
- Register routers in main.py

Frontend:
- Create SessionsProvider with React context
- Create sessions API client
- Update AppShell with sessions section in navigation
- Add session status indicators and polling
- Add CSS for session navigation

Quality gates: typecheck ✓, lint ✓, build ✓
This commit is contained in:
Fusion
2026-05-19 20:42:59 +02:00
parent c52367401b
commit c795f8f873
21 changed files with 1455 additions and 2 deletions
+97
View File
@@ -0,0 +1,97 @@
import { apiClient } from "./client";
export interface ToolInstance {
id: string;
name: string;
display_name: string;
tool_type_id: string;
status: string;
url: string | null;
port: number | null;
created_at: string;
}
export interface Session {
id: string;
display_name: string;
tool_type_name: string;
tool_icon: string;
repository_name: string;
project_name: string;
status: string;
url: string | null;
}
export async function listInstances(
projectId: string,
repoId: string
): Promise<ToolInstance[]> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances`
);
return response.data.instances;
}
export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
}
);
return response.data;
}
export async function startInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
);
return response.data;
}
export async function stopInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
);
return response.data;
}
export async function restartInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
);
return response.data;
}
export async function deleteInstance(
projectId: string,
repoId: string,
instanceId: string
): Promise<void> {
await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
);
}
export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions");
return response.data.sessions;
}
+51
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
@@ -13,9 +17,46 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/settings", label: "Settings", icon: "settings" }
];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
return (
<a
href={session.url || "#"}
target="_blank"
rel="noopener noreferrer"
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{session.display_name}</span>
</a>
);
};
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const loadSessions = useCallback(async () => {
try {
const data = await getUserSessions();
setAllSessions(data);
} catch {
// Silently fail - sessions are optional
}
}, [setAllSessions]);
useEffect(() => {
void loadSessions();
// Poll every 10 seconds
const interval = setInterval(() => {
void loadSessions();
}, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
return (
<div className="shell">
@@ -53,6 +94,16 @@ export const AppShell = () => {
{item.label}
</NavLink>
))}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className="shell-content">
+4 -1
View File
@@ -4,13 +4,16 @@ import { BrowserRouter } from "react-router-dom";
import { AppRouter } from "./router";
import { AuthProvider } from "./state/auth";
import { SessionsProvider } from "./state/sessions";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<AppRouter />
<SessionsProvider>
<AppRouter />
</SessionsProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
+41
View File
@@ -0,0 +1,41 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export interface Session {
id: string;
display_name: string;
tool_type_name: string;
tool_icon: string;
repository_name: string;
project_name: string;
status: string;
url: string | null;
}
interface SessionsContextType {
sessions: Session[];
setAllSessions: (sessions: Session[]) => void;
}
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const setAllSessions = (newSessions: Session[]) => {
setSessions(newSessions);
};
return (
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
{children}
</SessionsContext.Provider>
);
};
export const useSessions = () => {
const context = useContext(SessionsContext);
if (context === undefined) {
throw new Error("useSessions must be used within a SessionsProvider");
}
return context;
};
+43
View File
@@ -2217,3 +2217,46 @@ a.nav-item,
align-items: center;
}
}
/* Session Navigation */
.session-item {
position: relative;
padding-left: var(--space-6);
}
.session-status {
position: absolute;
left: var(--space-2);
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.session-status.running {
background: var(--success);
}
.session-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.nav-divider {
height: 1px;
background: var(--border);
margin: var(--space-2) var(--space-3);
}
.nav-section-title {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}