diff --git a/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md b/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md new file mode 100644 index 0000000..159623f --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md @@ -0,0 +1,1167 @@ +# 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 + + + + + + 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 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 { + 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 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 |