# 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 cameleer-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 Cameleer SaaS
``` - [ ] **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; deployedAt: string | null; stoppedAt: string | null; createdAt: string; } export interface LicenseResponse { id: string; tenantId: string; tier: string; features: Record; limits: Record; 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( ); ``` - [ ] **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 cameleer-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 { try { const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(atob(base64)); } catch { return {}; } } export const useAuthStore = create((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 (

Cameleer SaaS

Managed Apache Camel Runtime

); } ``` - [ ] **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 (
); } ``` - [ ] **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 ; 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(path: string, options: RequestInit = {}): Promise { const token = useAuthStore.getState().accessToken; const headers: Record = { ...(options.headers as Record || {}), }; 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: (path: string) => apiFetch(path), post: (path: string, body?: unknown) => apiFetch(path, { method: 'POST', body: body instanceof FormData ? body : JSON.stringify(body), }), patch: (path: string, body: unknown) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), put: (path: string, body: FormData) => apiFetch(path, { method: 'PUT', body }), delete: (path: string) => apiFetch(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(`/tenants/${tenantId}`), enabled: !!tenantId, }); } // License export function useLicense(tenantId: string) { return useQuery({ queryKey: ['license', tenantId], queryFn: () => api.get(`/tenants/${tenantId}/license`), enabled: !!tenantId, }); } // Environments export function useEnvironments(tenantId: string) { return useQuery({ queryKey: ['environments', tenantId], queryFn: () => api.get(`/tenants/${tenantId}/environments`), enabled: !!tenantId, }); } export function useCreateEnvironment(tenantId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (data: { slug: string; displayName: string }) => api.post(`/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(`/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(`/environments/${environmentId}/apps`), enabled: !!environmentId, }); } export function useApp(environmentId: string, appId: string) { return useQuery({ queryKey: ['app', appId], queryFn: () => api.get(`/environments/${environmentId}/apps/${appId}`), enabled: !!appId, }); } export function useCreateApp(environmentId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (formData: FormData) => api.post(`/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(`/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(`/apps/${appId}/deploy`), onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), }); } export function useDeployments(appId: string) { return useQuery({ queryKey: ['deployments', appId], queryFn: () => api.get(`/apps/${appId}/deployments`), enabled: !!appId, }); } export function useDeployment(appId: string, deploymentId: string) { return useQuery({ queryKey: ['deployment', deploymentId], queryFn: () => api.get(`/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(`/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(`/apps/${appId}/restart`), onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), }); } // Observability export function useAgentStatus(appId: string) { return useQuery({ queryKey: ['agent-status', appId], queryFn: () => api.get(`/apps/${appId}/agent-status`), enabled: !!appId, refetchInterval: 15_000, }); } export function useObservabilityStatus(appId: string) { return useQuery({ queryKey: ['observability-status', appId], queryFn: () => api.get(`/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(`/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 = { 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(); 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 = { BUILDING: 'warning', STARTING: 'warning', RUNNING: 'success', FAILED: 'error', STOPPED: 'default', }; export function DeploymentStatusBadge({ status }: { status: string }) { const variant = STATUS_COLORS[status] || 'default'; return {status}; } ``` - [ ] **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 `` 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 ( } /> } /> }> } /> } /> } /> } /> } /> ); } ``` - [ ] **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 cameleer-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 |