Files
cameleer-saas/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md

1168 lines
34 KiB
Markdown
Raw Normal View History

# Phase 9: Frontend React Shell — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a React SPA for managing tenants, environments, apps, and deployments. All backend APIs exist — this is the UI layer.
**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer3-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
**Tech Stack:** React 19, Vite 8, TypeScript, React Router 7, Zustand, TanStack React Query, @cameleer/design-system 0.1.31, Lucide React
---
## File Structure
All new files under `ui/`:
```
ui/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── .npmrc
├── src/
│ ├── main.tsx
│ ├── router.tsx
│ ├── auth/
│ │ ├── auth-store.ts
│ │ ├── LoginPage.tsx
│ │ ├── CallbackPage.tsx
│ │ └── ProtectedRoute.tsx
│ ├── api/
│ │ ├── client.ts
│ │ └── hooks.ts
│ ├── hooks/
│ │ └── usePermissions.ts
│ ├── components/
│ │ ├── RequirePermission.tsx
│ │ ├── Layout.tsx
│ │ ├── EnvironmentTree.tsx
│ │ └── DeploymentStatusBadge.tsx
│ ├── pages/
│ │ ├── DashboardPage.tsx
│ │ ├── EnvironmentsPage.tsx
│ │ ├── EnvironmentDetailPage.tsx
│ │ ├── AppDetailPage.tsx
│ │ └── LicensePage.tsx
│ └── types/
│ └── api.ts
```
Also modify:
- `src/main/java/.../config/SpaController.java` (new — catch-all for SPA routes)
- `docker-compose.yml` (Traefik SPA route)
- `.gitea/workflows/ci.yml` (add frontend build step)
- `HOWTO.md` (add frontend dev instructions)
---
## Task 1: Project Scaffolding
**Files:**
- Create: `ui/package.json`
- Create: `ui/.npmrc`
- Create: `ui/index.html`
- Create: `ui/vite.config.ts`
- Create: `ui/tsconfig.json`
- Create: `ui/src/main.tsx`
- Create: `ui/src/types/api.ts`
- [ ] **Step 1: Create package.json**
```json
{
"name": "cameleer-saas-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@cameleer/design-system": "0.1.31",
"@tanstack/react-query": "^5.90.0",
"lucide-react": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.13.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.9.0",
"vite": "^6.3.0"
}
}
```
- [ ] **Step 2: Create .npmrc**
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
```
- [ ] **Step 3: Create index.html**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cameleer SaaS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
- [ ] **Step 4: Create vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true,
},
});
```
- [ ] **Step 5: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}
```
- [ ] **Step 6: Create src/types/api.ts**
TypeScript types matching backend DTOs:
```typescript
export interface TenantResponse {
id: string;
name: string;
slug: string;
tier: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface EnvironmentResponse {
id: string;
tenantId: string;
slug: string;
displayName: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface AppResponse {
id: string;
environmentId: string;
slug: string;
displayName: string;
jarOriginalFilename: string | null;
jarSizeBytes: number | null;
jarChecksum: string | null;
exposedPort: number | null;
routeUrl: string | null;
currentDeploymentId: string | null;
previousDeploymentId: string | null;
createdAt: string;
updatedAt: string;
}
export interface DeploymentResponse {
id: string;
appId: string;
version: number;
imageRef: string;
desiredStatus: string;
observedStatus: string;
errorMessage: string | null;
orchestratorMetadata: Record<string, unknown>;
deployedAt: string | null;
stoppedAt: string | null;
createdAt: string;
}
export interface LicenseResponse {
id: string;
tenantId: string;
tier: string;
features: Record<string, boolean>;
limits: Record<string, number>;
issuedAt: string;
expiresAt: string;
token: string;
}
export interface AgentStatusResponse {
registered: boolean;
state: string;
lastHeartbeat: string | null;
routeIds: string[];
applicationId: string;
environmentId: string;
}
export interface ObservabilityStatusResponse {
hasTraces: boolean;
hasMetrics: boolean;
hasDiagrams: boolean;
lastTraceAt: string | null;
traceCount24h: number;
}
export interface LogEntry {
appId: string;
deploymentId: string;
timestamp: string;
stream: string;
message: string;
}
```
- [ ] **Step 7: Create src/main.tsx**
```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
import '@cameleer/design-system/style.css';
import { AppRouter } from './router';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 10_000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<ToastProvider>
<BreadcrumbProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</QueryClientProvider>
</BreadcrumbProvider>
</ToastProvider>
</ThemeProvider>
</React.StrictMode>
);
```
- [ ] **Step 8: Install dependencies**
```bash
cd ui && npm install
```
- [ ] **Step 9: Verify dev server starts**
```bash
cd ui && npx vite --host 2>&1 | head -5
```
Expected: Vite dev server starts on port 5173.
- [ ] **Step 10: Commit**
```bash
git add ui/
git commit -m "feat: scaffold React SPA with Vite, design system, and TypeScript types"
```
---
## Task 2: Auth Store + Login + Protected Route
**Files:**
- Create: `ui/src/auth/auth-store.ts`
- Create: `ui/src/auth/LoginPage.tsx`
- Create: `ui/src/auth/CallbackPage.tsx`
- Create: `ui/src/auth/ProtectedRoute.tsx`
- [ ] **Step 1: Create auth-store.ts**
Zustand store for auth state. Same localStorage keys as cameleer3-server SPA for SSO.
```typescript
import { create } from 'zustand';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
username: string | null;
roles: string[];
isAuthenticated: boolean;
login: (accessToken: string, refreshToken: string) => void;
logout: () => void;
loadFromStorage: () => void;
}
function parseJwt(token: string): Record<string, unknown> {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch {
return {};
}
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
username: null,
roles: [],
isAuthenticated: false,
login: (accessToken: string, refreshToken: string) => {
localStorage.setItem('cameleer-access-token', accessToken);
localStorage.setItem('cameleer-refresh-token', refreshToken);
const claims = parseJwt(accessToken);
const username = (claims.sub as string) || (claims.email as string) || 'user';
const roles = (claims.roles as string[]) || [];
localStorage.setItem('cameleer-username', username);
set({ accessToken, refreshToken, username, roles, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('cameleer-access-token');
localStorage.removeItem('cameleer-refresh-token');
localStorage.removeItem('cameleer-username');
set({ accessToken: null, refreshToken: null, username: null, roles: [], isAuthenticated: false });
},
loadFromStorage: () => {
const accessToken = localStorage.getItem('cameleer-access-token');
const refreshToken = localStorage.getItem('cameleer-refresh-token');
const username = localStorage.getItem('cameleer-username');
if (accessToken) {
const claims = parseJwt(accessToken);
const roles = (claims.roles as string[]) || [];
set({ accessToken, refreshToken, username, roles, isAuthenticated: true });
}
},
}));
```
- [ ] **Step 2: Create LoginPage.tsx**
```tsx
import { useEffect } from 'react';
import { Button } from '@cameleer/design-system';
export function LoginPage() {
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
const redirectUri = `${window.location.origin}/callback`;
const handleLogin = () => {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email offline_access',
});
window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
};
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div style={{ textAlign: 'center' }}>
<h1>Cameleer SaaS</h1>
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
Managed Apache Camel Runtime
</p>
<Button onClick={handleLogin}>Sign in with Logto</Button>
</div>
</div>
);
}
```
- [ ] **Step 3: Create CallbackPage.tsx**
```tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { Spinner } from '@cameleer/design-system';
export function CallbackPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) {
navigate('/login');
return;
}
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
const redirectUri = `${window.location.origin}/callback`;
fetch(`${logtoEndpoint}/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: redirectUri,
}),
})
.then((r) => r.json())
.then((data) => {
if (data.access_token) {
login(data.access_token, data.refresh_token || '');
navigate('/');
} else {
navigate('/login');
}
})
.catch(() => navigate('/login'));
}, [login, navigate]);
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}
```
- [ ] **Step 4: Create ProtectedRoute.tsx**
```tsx
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
```
- [ ] **Step 5: Commit**
```bash
git add ui/src/auth/
git commit -m "feat: add auth store, login, callback, and protected route"
```
---
## Task 3: API Client + React Query Hooks
**Files:**
- Create: `ui/src/api/client.ts`
- Create: `ui/src/api/hooks.ts`
- [ ] **Step 1: Create client.ts**
Fetch wrapper with Bearer token injection and 401 handling.
```typescript
import { useAuthStore } from '../auth/auth-store';
const API_BASE = '/api';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = useAuthStore.getState().accessToken;
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (response.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
if (response.status === 204) return undefined as T;
return response.json();
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
}),
patch: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: FormData) =>
apiFetch<T>(path, { method: 'PUT', body }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
};
```
- [ ] **Step 2: Create hooks.ts**
All React Query hooks for the backend API:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type {
TenantResponse, EnvironmentResponse, AppResponse,
DeploymentResponse, LicenseResponse, AgentStatusResponse,
ObservabilityStatusResponse, LogEntry,
} from '../types/api';
// Tenant
export function useTenant(tenantId: string) {
return useQuery({
queryKey: ['tenant', tenantId],
queryFn: () => api.get<TenantResponse>(`/tenants/${tenantId}`),
enabled: !!tenantId,
});
}
// License
export function useLicense(tenantId: string) {
return useQuery({
queryKey: ['license', tenantId],
queryFn: () => api.get<LicenseResponse>(`/tenants/${tenantId}/license`),
enabled: !!tenantId,
});
}
// Environments
export function useEnvironments(tenantId: string) {
return useQuery({
queryKey: ['environments', tenantId],
queryFn: () => api.get<EnvironmentResponse[]>(`/tenants/${tenantId}/environments`),
enabled: !!tenantId,
});
}
export function useCreateEnvironment(tenantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { slug: string; displayName: string }) =>
api.post<EnvironmentResponse>(`/tenants/${tenantId}/environments`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
export function useUpdateEnvironment(tenantId: string, envId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { displayName: string }) =>
api.patch<EnvironmentResponse>(`/tenants/${tenantId}/environments/${envId}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
export function useDeleteEnvironment(tenantId: string, envId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
// Apps
export function useApps(environmentId: string) {
return useQuery({
queryKey: ['apps', environmentId],
queryFn: () => api.get<AppResponse[]>(`/environments/${environmentId}/apps`),
enabled: !!environmentId,
});
}
export function useApp(environmentId: string, appId: string) {
return useQuery({
queryKey: ['app', appId],
queryFn: () => api.get<AppResponse>(`/environments/${environmentId}/apps/${appId}`),
enabled: !!appId,
});
}
export function useCreateApp(environmentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (formData: FormData) =>
api.post<AppResponse>(`/environments/${environmentId}/apps`, formData),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
});
}
export function useDeleteApp(environmentId: string, appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
});
}
export function useUpdateRouting(environmentId: string, appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { exposedPort: number | null }) =>
api.patch<AppResponse>(`/environments/${environmentId}/apps/${appId}/routing`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }),
});
}
// Deployments
export function useDeploy(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/deploy`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
});
}
export function useDeployments(appId: string) {
return useQuery({
queryKey: ['deployments', appId],
queryFn: () => api.get<DeploymentResponse[]>(`/apps/${appId}/deployments`),
enabled: !!appId,
});
}
export function useDeployment(appId: string, deploymentId: string) {
return useQuery({
queryKey: ['deployment', deploymentId],
queryFn: () => api.get<DeploymentResponse>(`/apps/${appId}/deployments/${deploymentId}`),
enabled: !!deploymentId,
refetchInterval: (query) => {
const status = query.state.data?.observedStatus;
return status === 'BUILDING' || status === 'STARTING' ? 3000 : false;
},
});
}
export function useStop(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/stop`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['deployments', appId] });
qc.invalidateQueries({ queryKey: ['app'] });
},
});
}
export function useRestart(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/restart`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
});
}
// Observability
export function useAgentStatus(appId: string) {
return useQuery({
queryKey: ['agent-status', appId],
queryFn: () => api.get<AgentStatusResponse>(`/apps/${appId}/agent-status`),
enabled: !!appId,
refetchInterval: 15_000,
});
}
export function useObservabilityStatus(appId: string) {
return useQuery({
queryKey: ['observability-status', appId],
queryFn: () => api.get<ObservabilityStatusResponse>(`/apps/${appId}/observability-status`),
enabled: !!appId,
refetchInterval: 30_000,
});
}
export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) {
return useQuery({
queryKey: ['logs', appId, params],
queryFn: () => {
const qs = new URLSearchParams();
if (params?.since) qs.set('since', params.since);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.stream) qs.set('stream', params.stream);
const query = qs.toString();
return api.get<LogEntry[]>(`/apps/${appId}/logs${query ? `?${query}` : ''}`);
},
enabled: !!appId,
});
}
```
- [ ] **Step 3: Commit**
```bash
git add ui/src/api/
git commit -m "feat: add API client with auth middleware and React Query hooks"
```
---
## Task 4: RBAC Hooks + Components
**Files:**
- Create: `ui/src/hooks/usePermissions.ts`
- Create: `ui/src/components/RequirePermission.tsx`
- Create: `ui/src/components/DeploymentStatusBadge.tsx`
- [ ] **Step 1: Create usePermissions.ts**
```typescript
import { useAuthStore } from '../auth/auth-store';
const ROLE_PERMISSIONS: Record<string, string[]> = {
OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
ADMIN: ['team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
DEVELOPER: ['apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug'],
VIEWER: ['observe:read'],
};
export function usePermissions() {
const roles = useAuthStore((s) => s.roles);
const permissions = new Set<string>();
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms) perms.forEach((p) => permissions.add(p));
}
return {
has: (permission: string) => permissions.has(permission),
canManageApps: permissions.has('apps:manage'),
canDeploy: permissions.has('apps:deploy'),
canManageTenant: permissions.has('tenant:manage'),
canViewObservability: permissions.has('observe:read'),
roles,
};
}
```
- [ ] **Step 2: Create RequirePermission.tsx**
```tsx
import { usePermissions } from '../hooks/usePermissions';
interface Props {
permission: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function RequirePermission({ permission, children, fallback }: Props) {
const { has } = usePermissions();
if (!has(permission)) return fallback ? <>{fallback}</> : null;
return <>{children}</>;
}
```
- [ ] **Step 3: Create DeploymentStatusBadge.tsx**
```tsx
import { Badge } from '@cameleer/design-system';
const STATUS_COLORS: Record<string, string> = {
BUILDING: 'warning',
STARTING: 'warning',
RUNNING: 'success',
FAILED: 'error',
STOPPED: 'default',
};
export function DeploymentStatusBadge({ status }: { status: string }) {
const variant = STATUS_COLORS[status] || 'default';
return <Badge variant={variant as any}>{status}</Badge>;
}
```
- [ ] **Step 4: Commit**
```bash
git add ui/src/hooks/ ui/src/components/RequirePermission.tsx ui/src/components/DeploymentStatusBadge.tsx
git commit -m "feat: add RBAC hooks and permission-gated components"
```
---
## Task 5: Layout + Router
**Files:**
- Create: `ui/src/components/Layout.tsx`
- Create: `ui/src/components/EnvironmentTree.tsx`
- Create: `ui/src/router.tsx`
- [ ] **Step 1: Create Layout.tsx**
The main layout using AppShell + Sidebar from the design system. This provides the sidebar navigation shell wrapping all protected pages.
The sidebar contains:
- Logo / brand
- Dashboard link
- Environments section with expandable tree (EnvironmentTree component)
- License link
- Divider
- "View Dashboard" external link to `/dashboard`
- User info + logout at bottom
Use the `AppShell` and `Sidebar` components from the design system. If those components expect specific props, adapt accordingly — read the design system exports to understand the expected API.
The layout wraps a React Router `<Outlet />` for nested routes.
- [ ] **Step 2: Create EnvironmentTree.tsx**
Fetches environments + apps and renders a collapsible tree in the sidebar. Each environment expands to show its apps. Clicking an app navigates to the app detail page.
Uses the `TreeView` component from the design system if available, or a simple nested list otherwise.
- [ ] **Step 3: Create router.tsx**
```tsx
import { Routes, Route } from 'react-router';
import { useEffect } from 'react';
import { useAuthStore } from './auth/auth-store';
import { LoginPage } from './auth/LoginPage';
import { CallbackPage } from './auth/CallbackPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { Layout } from './components/Layout';
import { DashboardPage } from './pages/DashboardPage';
import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
import { AppDetailPage } from './pages/AppDetailPage';
import { LicensePage } from './pages/LicensePage';
export function AppRouter() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
useEffect(() => { loadFromStorage(); }, [loadFromStorage]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/callback" element={<CallbackPage />} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="environments" element={<EnvironmentsPage />} />
<Route path="environments/:envId" element={<EnvironmentDetailPage />} />
<Route path="environments/:envId/apps/:appId" element={<AppDetailPage />} />
<Route path="license" element={<LicensePage />} />
</Route>
</Routes>
);
}
```
- [ ] **Step 4: Commit**
```bash
git add ui/src/components/Layout.tsx ui/src/components/EnvironmentTree.tsx ui/src/router.tsx
git commit -m "feat: add sidebar layout, environment tree, and router"
```
---
## Task 6: Dashboard Page
**Files:**
- Create: `ui/src/pages/DashboardPage.tsx`
- [ ] **Step 1: Create DashboardPage.tsx**
Shows tenant overview:
- Tenant name + tier badge
- KPI strip: environment count, total apps, running apps, failed apps
- Recent deployments table (across all environments)
- Quick action buttons: "New Environment" (ADMIN+), "View Observability Dashboard" (link to /dashboard)
Uses design system components: `Card`, `StatCard`/`KpiStrip`, `DataTable`, `Badge`, `Button`.
The page needs the tenant ID. Since we're in single-tenant Docker mode, the tenant can be fetched via the first tenant from the API, or stored in a context after login. For simplicity, fetch environments list and derive stats from it. Use `useEnvironments` and `useApps` hooks.
**Implementation approach:** Fetch all environments for the tenant. For each environment, fetch apps. Aggregate counts. Display in KPI strip + table.
Note: The tenant ID needs to come from somewhere. Options:
- Extract from the JWT `organization_id` claim
- Fetch `/api/tenants` and use the first result
- Store in a tenant context after initial load
Use the JWT `organization_id` claim approach — the auth store already parses the JWT. Add `tenantId` to the auth store extracted from the `organization_id` claim. If not present, fall back to fetching the first tenant from the API.
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/DashboardPage.tsx
git commit -m "feat: add dashboard page with tenant overview and KPI stats"
```
---
## Task 7: Environments Page + Environment Detail Page
**Files:**
- Create: `ui/src/pages/EnvironmentsPage.tsx`
- Create: `ui/src/pages/EnvironmentDetailPage.tsx`
- [ ] **Step 1: Create EnvironmentsPage.tsx**
List of environments with:
- DataTable: display name, slug, status, app count, created date
- "Create Environment" button (ADMIN+ only) — opens a modal/dialog with slug + display name fields
- Click row → navigate to `/environments/:id`
Uses: `DataTable`, `Button`, `Modal`, `RequirePermission`.
- [ ] **Step 2: Create EnvironmentDetailPage.tsx**
Environment detail with:
- Header: display name (inline editable for ADMIN+), slug, status badge
- App list table: display name, slug, deployment status, agent status, last deployed
- "New App" button (DEVELOPER+) — opens JAR upload dialog (file picker + slug/displayName fields)
- "Delete Environment" button (ADMIN+, disabled if apps exist)
- Click app row → navigate to `/environments/:eid/apps/:aid`
Uses: `Card`, `DataTable`, `Button`, `Modal`, `InlineEdit`, `RequirePermission`, `DeploymentStatusBadge`.
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/EnvironmentsPage.tsx ui/src/pages/EnvironmentDetailPage.tsx
git commit -m "feat: add environments list and environment detail pages"
```
---
## Task 8: App Detail Page
**Files:**
- Create: `ui/src/pages/AppDetailPage.tsx`
- [ ] **Step 1: Create AppDetailPage.tsx**
The main app management page with sections:
**Header:** App name, slug, breadcrumb (Environment > App)
**Status card:** Current deployment status with auto-refreshing badge. Shows version number, image ref. Polls every 3s while BUILDING/STARTING.
**Action bar:** Deploy, Stop, Restart buttons (DEVELOPER+). Re-upload JAR button (DEVELOPER+). Delete app button (ADMIN+). Each action uses the corresponding mutation hook with confirmation dialogs for destructive actions.
**Agent status card:** Registered/not, state badge, route IDs list. "View in Dashboard" link to `/dashboard`.
**Routing card:** Exposed port, route URL (clickable link). "Edit Routing" button (ADMIN+) with port input.
**Deployment history:** DataTable with version, status badge, deployed/stopped timestamps, error message.
**Container logs:** LogViewer or simple code block showing log lines. Stream filter (stdout/stderr/both). Limit control. Auto-refresh.
Uses: `Card`, `Button`, `DataTable`, `Badge`, `CodeBlock`, `Modal`, `ConfirmDialog`, `Tabs`, `RequirePermission`, `DeploymentStatusBadge`, and the mutation hooks (useDeploy, useStop, useRestart, useDeleteApp, useUpdateRouting).
This is the largest page. Keep it well-structured with clear sections.
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/AppDetailPage.tsx
git commit -m "feat: add app detail page with deploy, logs, and status"
```
---
## Task 9: License Page
**Files:**
- Create: `ui/src/pages/LicensePage.tsx`
- [ ] **Step 1: Create LicensePage.tsx**
Simple page showing:
- Tier badge (LOW/MID/HIGH/BUSINESS)
- Feature matrix: topology, lineage, correlation, debugger, replay — each with enabled/disabled badge
- Limits: max agents, retention days, max environments
- Expiry date + days remaining
- License token (collapsed, expandable code block)
Uses: `Card`, `Badge`, `CodeBlock`.
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/LicensePage.tsx
git commit -m "feat: add license page with tier features and limits"
```
---
## Task 10: Spring Boot SPA Controller + Traefik + CI
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/config/SpaController.java`
- Modify: `docker-compose.yml`
- Modify: `.gitea/workflows/ci.yml`
- Modify: `HOWTO.md`
- [ ] **Step 1: Create SpaController.java**
```java
package net.siegeln.cameleer.saas.config;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SpaController {
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
public String spa() {
return "forward:/index.html";
}
}
```
- [ ] **Step 2: Update docker-compose.yml — add SPA route**
Add to cameleer-saas labels (lowest priority, catches everything not matched by other routes):
```yaml
- traefik.http.routers.spa.rule=PathPrefix(`/`)
- traefik.http.routers.spa.priority=1
- traefik.http.services.spa.loadbalancer.server.port=8080
```
- [ ] **Step 3: Update CI pipeline**
Add frontend build step before Maven in the build job:
```yaml
- name: Build Frontend
run: |
cd ui
npm ci
npm run build
```
This outputs to `src/main/resources/static/` (configured in vite.config.ts). The subsequent `mvn clean verify` packages the SPA into the JAR.
Add Vite environment variables for the build:
```yaml
env:
VITE_LOGTO_ENDPOINT: ${{ secrets.LOGTO_ENDPOINT }}
VITE_LOGTO_CLIENT_ID: ${{ secrets.LOGTO_CLIENT_ID }}
```
- [ ] **Step 4: Update HOWTO.md**
Add a "Frontend Development" section after the existing "Development" section:
- How to run the frontend dev server (`cd ui && npm run dev`)
- How to build for production (`cd ui && npm run build`)
- Environment variables needed: `VITE_LOGTO_ENDPOINT`, `VITE_LOGTO_CLIENT_ID`
- Note about Vite proxy for API calls in dev mode
- [ ] **Step 5: Verify Maven compilation**
```bash
mvn compile -B -q
```
- [ ] **Step 6: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/SpaController.java \
docker-compose.yml .gitea/workflows/ci.yml HOWTO.md
git commit -m "feat: add SPA controller, Traefik route, CI frontend build, and HOWTO update"
```
---
## Summary of Spec Coverage
| Spec Requirement | Task |
|---|---|
| Project scaffolding (Vite, React, TS, design system) | Task 1 |
| TypeScript API types | Task 1 |
| Auth store (Zustand, same keys as cameleer3-server) | Task 2 |
| Login / Logto OIDC redirect / callback | Task 2 |
| Protected route | Task 2 |
| API client with auth middleware | Task 3 |
| React Query hooks for all endpoints | Task 3 |
| RBAC permissions hook | Task 4 |
| RequirePermission component | Task 4 |
| DeploymentStatusBadge | Task 4 |
| Sidebar layout (AppShell + Sidebar) | Task 5 |
| Environment tree navigation | Task 5 |
| Router with all page routes | Task 5 |
| Dashboard page (tenant overview, KPIs) | Task 6 |
| Environments list page | Task 7 |
| Environment detail page (app list, create app) | Task 7 |
| App detail page (deploy, stop, logs, status, routing) | Task 8 |
| License page | Task 9 |
| Spring Boot SPA controller | Task 10 |
| Traefik SPA routing | Task 10 |
| CI frontend build | Task 10 |
| HOWTO.md updates | Task 10 |