1168 lines
34 KiB
Markdown
1168 lines
34 KiB
Markdown
|
|
# 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
|
||
|
|
<!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**
|
||
|
|
|
||
|
|
```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<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**
|
||
|
|
|
||
|
|
```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**
|
||
|
|
|
||
|
|
```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<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**
|
||
|
|
|
||
|
|
```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**
|
||
|
|
|
||
|
|
```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**
|
||
|
|
|
||
|
|
```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**
|
||
|
|
|
||
|
|
```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<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:
|
||
|
|
|
||
|
|
```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<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**
|
||
|
|
|
||
|
|
```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<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**
|
||
|
|
|
||
|
|
```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<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**
|
||
|
|
|
||
|
|
```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 `<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**
|
||
|
|
|
||
|
|
```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**
|
||
|
|
|
||
|
|
```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 |
|