12 KiB
Phase 9: Frontend React Shell
Date: 2026-04-04 Status: Draft Depends on: Phase 4 (Observability Pipeline + Inbound Routing) Gitea issue: #31
Context
Phases 1-4 built the complete backend: tenants, licensing, environments, app deployment with JAR upload, async deployment pipeline, container logs, agent status, observability status, and inbound HTTP routing. The cameleer3-server observability dashboard is already served at /dashboard. But there is no management UI — all operations require curl/API calls.
Phase 9 adds the SaaS management shell: a React SPA for managing tenants, environments, apps, and deployments. The observability UI is already handled by cameleer3-server — this shell covers everything else.
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Location | ui/ directory in cameleer-saas repo |
Matches cameleer3-server pattern. Single build pipeline. Spring Boot serves the SPA. |
| Relationship to dashboard | Two separate SPAs, linked via navigation | SaaS shell at /, observability at /dashboard. Same design system = cohesive feel. No coupling. |
| Layout | Sidebar navigation | Consistent with cameleer3-server dashboard. Same AppShell pattern from design system. |
| Auth | Shared Logto OIDC session | Same client ID, same localStorage keys. True SSO between SaaS shell and observability dashboard. |
| Tech stack | React 19 + Vite + React Router + Zustand + TanStack Query | Identical to cameleer3-server SPA. Same patterns, same libraries, same conventions. |
| Design system | @cameleer/design-system v0.1.31 |
Shared component library. CSS Modules + design tokens. Dark theme. |
| RBAC | Frontend role-based visibility | Roles from JWT claims. Hide/disable UI for unauthorized actions. Backend enforces — frontend is UX only. |
Tech Stack
- React 19 + TypeScript
- Vite 8 (bundler + dev server)
- React Router 7 (client-side routing)
- Zustand (auth state store)
- TanStack React Query (data fetching + caching)
- @cameleer/design-system (UI components)
- Lucide React (icons)
Auth Flow
- User navigates to
/—ProtectedRoutechecksuseAuthStore.isAuthenticated - If not authenticated, redirect to Logto OIDC authorize endpoint
- Logto callback at
/callback— exchange code for tokens - Store
accessToken,refreshToken,username,rolesin Zustand + localStorage - Tokens stored with same keys as cameleer3-server SPA:
cameleer-access-token,cameleer-refresh-token - API client injects
Authorization: Bearer {token}on all requests - On 401, attempt token refresh; on failure, redirect to login
RBAC Model
Roles from JWT or API response:
| Role | Permissions | UI Access |
|---|---|---|
| OWNER | All | Everything + tenant settings |
| ADMIN | All except tenant:manage, billing:manage | Environments CRUD, apps CRUD, routing, deploy |
| DEVELOPER | apps:deploy, secrets:manage, observe:read, observe:debug | Deploy, stop, restart, re-upload JAR, view logs |
| VIEWER | observe:read | View-only: dashboard, app status, logs, deployment history |
Frontend RBAC implementation:
usePermissions()hook reads roles from auth store, returns permission checks<RequirePermission permission="apps:deploy">wrapper component hides children if unauthorized- Buttons/actions disabled with tooltip "Insufficient permissions" for unauthorized roles
- Navigation items hidden entirely if user has no access to any action on that page
Pages
Login (/login)
- Logto OIDC redirect button
- Handles callback at
/callback - Stores tokens, redirects to
/
Dashboard (/)
- Tenant overview: name, tier badge, license expiry
- Environment count, total app count
- Running/failed/stopped app summary (KPI strip)
- Recent deployments table (last 10)
- Quick actions: "New Environment", "View Observability Dashboard"
- All roles can view
Environments (/environments)
- Table: name (display_name), slug, app count, status badge
- "Create Environment" button (ADMIN+ only, enforces tier limit)
- Click row → navigate to environment detail
- All roles can view list
Environment Detail (/environments/:id)
- Environment name (editable inline for ADMIN+), slug, status
- App list table: name, slug, deployment status, agent status, last deployed
- "New App" button (DEVELOPER+ only) — opens JAR upload dialog
- "Delete Environment" button (ADMIN+ only, disabled if apps exist)
- All roles can view
App Detail (/environments/:eid/apps/:aid)
- Header: app name, slug, environment breadcrumb
- Status card: current deployment status (BUILDING/STARTING/RUNNING/FAILED/STOPPED) with auto-refresh polling (3s)
- Agent status card: registered/not, state, route IDs, link to observability dashboard
- JAR info: filename, size, checksum, upload date
- Routing card: exposed port, route URL (clickable), edit button (ADMIN+)
- Actions bar:
- Deploy (DEVELOPER+) — triggers new deployment
- Stop (DEVELOPER+)
- Restart (DEVELOPER+)
- Re-upload JAR (DEVELOPER+) — file picker dialog
- Delete app (ADMIN+) — confirmation dialog
- Deployment history: table with version, status, timestamps, error messages
- Container logs: LogViewer component from design system, auto-refresh, stream filter (stdout/stderr)
- All roles can view status/logs/history
License (/license)
- Current tier badge, features enabled/disabled, limits
- Expiry date, days remaining
- All roles can view
Sidebar Navigation
🐪 Cameleer SaaS
─────────────────
📊 Dashboard
🌍 Environments
└ {env-name} (expandable, shows apps)
└ {app-name}
📄 License
─────────────────
👁 View Dashboard → (links to /dashboard)
─────────────────
🔒 Logged in as {name}
Logout
- Sidebar uses
Sidebar+TreeViewcomponents from design system - Environment → App hierarchy is collapsible
- "View Dashboard" is an external link to
/dashboard(cameleer3-server SPA) - Sidebar collapses on small screens (responsive)
API Integration
The SaaS shell talks to cameleer-saas REST API. All endpoints already exist from Phases 1-4.
API Client Setup
- Vite proxy:
/api→http://localhost:8080(dev mode) - Production: Traefik routes
/apito cameleer-saas - Auth middleware injects Bearer token
- Handles 401/403 with refresh + redirect
React Query Hooks
useTenant() → GET /api/tenants/{id}
useLicense(tenantId) → GET /api/tenants/{tid}/license
useEnvironments(tenantId) → GET /api/tenants/{tid}/environments
useCreateEnvironment(tenantId) → POST /api/tenants/{tid}/environments
useUpdateEnvironment(tenantId, eid) → PATCH /api/tenants/{tid}/environments/{eid}
useDeleteEnvironment(tenantId, eid) → DELETE /api/tenants/{tid}/environments/{eid}
useApps(environmentId) → GET /api/environments/{eid}/apps
useCreateApp(environmentId) → POST /api/environments/{eid}/apps (multipart)
useDeleteApp(environmentId, appId) → DELETE /api/environments/{eid}/apps/{aid}
useUpdateRouting(environmentId, aid) → PATCH /api/environments/{eid}/apps/{aid}/routing
useDeploy(appId) → POST /api/apps/{aid}/deploy
useDeployments(appId) → GET /api/apps/{aid}/deployments
useDeployment(appId, did) → GET /api/apps/{aid}/deployments/{did} (poll 3s)
useStop(appId) → POST /api/apps/{aid}/stop
useRestart(appId) → POST /api/apps/{aid}/restart
useAgentStatus(appId) → GET /api/apps/{aid}/agent-status
useObservabilityStatus(appId) → GET /api/apps/{aid}/observability-status
useLogs(appId) → GET /api/apps/{aid}/logs
File Structure
ui/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── main.tsx — React root + providers
│ ├── router.tsx — React Router config
│ ├── auth/
│ │ ├── auth-store.ts — Zustand store (same keys as cameleer3-server)
│ │ ├── LoginPage.tsx
│ │ ├── CallbackPage.tsx
│ │ └── ProtectedRoute.tsx
│ ├── api/
│ │ ├── client.ts — fetch wrapper with auth middleware
│ │ └── hooks.ts — React Query hooks for all endpoints
│ ├── hooks/
│ │ └── usePermissions.ts — RBAC permission checks
│ ├── components/
│ │ ├── RequirePermission.tsx — RBAC wrapper
│ │ ├── Layout.tsx — AppShell + Sidebar + Breadcrumbs
│ │ ├── EnvironmentTree.tsx — Sidebar tree (envs → apps)
│ │ └── DeploymentStatusBadge.tsx
│ ├── pages/
│ │ ├── DashboardPage.tsx
│ │ ├── EnvironmentsPage.tsx
│ │ ├── EnvironmentDetailPage.tsx
│ │ ├── AppDetailPage.tsx
│ │ └── LicensePage.tsx
│ └── types/
│ └── api.ts — TypeScript types matching backend DTOs
Traefik Routing
cameleer-saas:
labels:
# Existing API routes:
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
# New SPA route:
- traefik.http.routers.spa.rule=PathPrefix(`/`)
- traefik.http.routers.spa.priority=1
- traefik.http.services.spa.loadbalancer.server.port=8080
Spring Boot serves the SPA from src/main/resources/static/ (built by Vite into this directory). A catch-all controller returns index.html for all non-API routes (SPA client-side routing).
Build Integration
Vite Build → Spring Boot Static Resources
# In ui/
npm run build
# Output: ui/dist/
# Copy to Spring Boot static resources
cp -r ui/dist/* src/main/resources/static/
This can be automated in the Maven build via frontend-maven-plugin or a simple shell script in CI.
CI Pipeline
Add a ui-build step before mvn verify:
cd ui && npm ci && npm run build- Copy
ui/dist/tosrc/main/resources/static/ mvn clean verifypackages the SPA into the JAR
Development
# Terminal 1: backend
mvn spring-boot:run
# Terminal 2: frontend (Vite dev server with API proxy)
cd ui && npm run dev
Vite dev server proxies /api to localhost:8080.
SPA Catch-All Controller
Spring Boot needs a catch-all to serve index.html for SPA routes:
@Controller
public class SpaController {
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
public String spa() {
return "forward:/index.html";
}
}
This ensures React Router handles client-side routing. API routes (/api/**) are not caught — they go to the existing REST controllers.
Design System Integration
{
"dependencies": {
"@cameleer/design-system": "0.1.31"
}
}
Registry configuration in .npmrc:
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
Import in main.tsx:
import '@cameleer/design-system/style.css';
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
Verification Plan
npm run devstarts Vite dev server, SPA loads at localhost:5173- Login redirects to Logto, callback stores tokens
- Dashboard shows tenant overview with correct data from API
- Environment list loads, create/rename/delete works (ADMIN+)
- App upload (JAR + metadata) works, app appears in list
- Deploy triggers async deployment, status polls and updates live
- Agent status shows registered/connected
- Container logs stream in LogViewer
- "View Dashboard" link navigates to
/dashboard(cameleer3-server SPA) - Shared auth: no re-login when switching between SPAs
- RBAC: VIEWER cannot see deploy button, DEVELOPER cannot delete environments
- Production build:
npm run build+mvn packageproduces JAR with embedded SPA
What Phase 9 Does NOT Touch
- No changes to cameleer3-server or its SPA
- No billing UI (Phase 6)
- No team management (Logto org admin — deferred)
- No tenant settings/profile page
- No super-admin multi-tenant view