Files
cameleer-saas/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
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>
2026-04-15 15:28:44 +02:00

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_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
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