Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer, update all references in workflows, Docker configs, docs, and bootstrap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
34 KiB
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
{
"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
<!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
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
{
"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:
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
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
cd ui && npm install
- Step 9: Verify dev server starts
cd ui && npx vite --host 2>&1 | head -5
Expected: Vite dev server starts on port 5173.
- Step 10: Commit
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.
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
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
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
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
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.
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:
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
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
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
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
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
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
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
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_idclaim - Fetch
/api/tenantsand 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
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
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
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
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
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):
- 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:
- 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:
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
mvn compile -B -q
- Step 6: Commit
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 |