Compare commits
14 Commits
feat/phase
...
050ff61e7a
| Author | SHA1 | Date | |
|---|---|---|---|
| 050ff61e7a | |||
|
|
e325c4d2c0 | ||
|
|
4c8c8efbe5 | ||
|
|
f6d3627abc | ||
|
|
fe786790e1 | ||
|
|
5eac48ad72 | ||
|
|
02019e9347 | ||
|
|
91a4235223 | ||
|
|
e725669aef | ||
|
|
d572926010 | ||
|
|
e33818cc74 | ||
|
|
146dbccc6e | ||
|
|
600985c913 | ||
| 7aa331d73c |
@@ -27,6 +27,12 @@ jobs:
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-maven-
|
||||
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build and Test (unit tests only)
|
||||
run: >-
|
||||
mvn clean verify -B
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /ui
|
||||
COPY ui/package.json ui/package-lock.json ui/.npmrc ./
|
||||
RUN npm ci
|
||||
COPY ui/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||
WORKDIR /build
|
||||
COPY .mvn/ .mvn/
|
||||
COPY mvnw pom.xml ./
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B
|
||||
COPY src/ src/
|
||||
COPY --from=frontend /src/main/resources/static/ src/main/resources/static/
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
46
HOWTO.md
46
HOWTO.md
@@ -302,6 +302,52 @@ Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream`
|
||||
| HIGH | Unlimited | 50 | 90 days | + Debugger, Replay |
|
||||
| BUSINESS | Unlimited | Unlimited | 365 days | All features |
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The SaaS management UI is a React SPA in the `ui/` directory.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev Server
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server starts on http://localhost:5173 and proxies `/api` to `http://localhost:8080` (the Spring Boot backend). Run the backend in another terminal with `mvn spring-boot:run` or via Docker Compose.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `VITE_LOGTO_ENDPOINT` | Logto OIDC endpoint | `http://localhost:3001` |
|
||||
| `VITE_LOGTO_CLIENT_ID` | Logto application client ID | (empty) |
|
||||
|
||||
Create a `ui/.env.local` file for local overrides:
|
||||
```bash
|
||||
VITE_LOGTO_ENDPOINT=http://localhost:3001
|
||||
VITE_LOGTO_CLIENT_ID=your-client-id
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output goes to `src/main/resources/static/` (configured in `vite.config.ts`). The subsequent `mvn package` bundles the SPA into the JAR. In Docker builds, the Dockerfile handles this automatically via a multi-stage build.
|
||||
|
||||
### SPA Routing
|
||||
|
||||
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer3-server) is at `/dashboard`.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -79,6 +79,9 @@ services:
|
||||
- traefik.http.services.api.loadbalancer.server.port=8080
|
||||
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
||||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||||
- traefik.http.routers.spa.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.spa.priority=1
|
||||
- traefik.http.services.spa.loadbalancer.server.port=8080
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# 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
|
||||
|
||||
1. User navigates to `/` — `ProtectedRoute` checks `useAuthStore.isAuthenticated`
|
||||
2. If not authenticated, redirect to Logto OIDC authorize endpoint
|
||||
3. Logto callback at `/callback` — exchange code for tokens
|
||||
4. Store `accessToken`, `refreshToken`, `username`, `roles` in Zustand + localStorage
|
||||
5. Tokens stored with same keys as cameleer3-server SPA: `cameleer-access-token`, `cameleer-refresh-token`
|
||||
6. API client injects `Authorization: Bearer {token}` on all requests
|
||||
7. 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` + `TreeView` components 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 `/api` to 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
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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`:
|
||||
1. `cd ui && npm ci && npm run build`
|
||||
2. Copy `ui/dist/` to `src/main/resources/static/`
|
||||
3. `mvn clean verify` packages the SPA into the JAR
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```java
|
||||
@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
|
||||
|
||||
```json
|
||||
{
|
||||
"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`:
|
||||
```tsx
|
||||
import '@cameleer/design-system/style.css';
|
||||
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
|
||||
```
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. `npm run dev` starts Vite dev server, SPA loads at localhost:5173
|
||||
2. Login redirects to Logto, callback stores tokens
|
||||
3. Dashboard shows tenant overview with correct data from API
|
||||
4. Environment list loads, create/rename/delete works (ADMIN+)
|
||||
5. App upload (JAR + metadata) works, app appears in list
|
||||
6. Deploy triggers async deployment, status polls and updates live
|
||||
7. Agent status shows registered/connected
|
||||
8. Container logs stream in LogViewer
|
||||
9. "View Dashboard" link navigates to `/dashboard` (cameleer3-server SPA)
|
||||
10. Shared auth: no re-login when switching between SPAs
|
||||
11. RBAC: VIEWER cannot see deploy button, DEVELOPER cannot delete environments
|
||||
12. Production build: `npm run build` + `mvn package` produces 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
|
||||
@@ -0,0 +1,13 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
4
ui/.gitignore
vendored
Normal file
4
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
1
ui/.npmrc
Normal file
1
ui/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||
12
ui/index.html
Normal file
12
ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
1962
ui/package-lock.json
generated
Normal file
1962
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
ui/package.json
Normal file
27
ui/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
46
ui/src/api/client.ts
Normal file
46
ui/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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' }),
|
||||
};
|
||||
185
ui/src/api/hooks.ts
Normal file
185
ui/src/api/hooks.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
49
ui/src/auth/CallbackPage.tsx
Normal file
49
ui/src/auth/CallbackPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
29
ui/src/auth/LoginPage.tsx
Normal file
29
ui/src/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
8
ui/src/auth/ProtectedRoute.tsx
Normal file
8
ui/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
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}</>;
|
||||
}
|
||||
61
ui/src/auth/auth-store.ts
Normal file
61
ui/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
username: string | null;
|
||||
roles: string[];
|
||||
tenantId: string | null;
|
||||
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: [],
|
||||
tenantId: null,
|
||||
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[]) || [];
|
||||
const tenantId = (claims.organization_id as string) || null;
|
||||
localStorage.setItem('cameleer-username', username);
|
||||
set({ accessToken, refreshToken, username, roles, tenantId, 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: [], tenantId: null, 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[]) || [];
|
||||
const tenantId = (claims.organization_id as string) || null;
|
||||
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
15
ui/src/components/DeploymentStatusBadge.tsx
Normal file
15
ui/src/components/DeploymentStatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Badge } from '@cameleer/design-system';
|
||||
|
||||
// Badge color values: 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
|
||||
const STATUS_COLORS: Record<string, 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'> = {
|
||||
BUILDING: 'warning',
|
||||
STARTING: 'warning',
|
||||
RUNNING: 'running',
|
||||
FAILED: 'error',
|
||||
STOPPED: 'auto',
|
||||
};
|
||||
|
||||
export function DeploymentStatusBadge({ status }: { status: string }) {
|
||||
const color = STATUS_COLORS[status] ?? 'auto';
|
||||
return <Badge label={status} color={color} />;
|
||||
}
|
||||
114
ui/src/components/EnvironmentTree.tsx
Normal file
114
ui/src/components/EnvironmentTree.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useEnvironments, useApps } from '../api/hooks';
|
||||
import type { EnvironmentResponse } from '../types/api';
|
||||
|
||||
/**
|
||||
* Renders one environment entry as a SidebarTreeNode.
|
||||
* This is a "render nothing, report data" component: it fetches apps for
|
||||
* the given environment and invokes `onNode` with the assembled tree node
|
||||
* whenever the data changes.
|
||||
*
|
||||
* Using a dedicated component per env is the idiomatic way to call a hook
|
||||
* for each item in a dynamic list without violating Rules of Hooks.
|
||||
*/
|
||||
function EnvWithApps({
|
||||
env,
|
||||
onNode,
|
||||
}: {
|
||||
env: EnvironmentResponse;
|
||||
onNode: (node: SidebarTreeNode) => void;
|
||||
}) {
|
||||
const { data: apps } = useApps(env.id);
|
||||
|
||||
const children: SidebarTreeNode[] = (apps ?? []).map((app) => ({
|
||||
id: app.id,
|
||||
label: app.displayName,
|
||||
path: `/environments/${env.id}/apps/${app.id}`,
|
||||
}));
|
||||
|
||||
const node: SidebarTreeNode = {
|
||||
id: env.id,
|
||||
label: env.displayName,
|
||||
path: `/environments/${env.id}`,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
};
|
||||
|
||||
// Calling onNode during render is intentional here: we want the parent to
|
||||
// collect the latest node on every render. The parent guards against
|
||||
// infinite loops by doing a shallow equality check before updating state.
|
||||
onNode(node);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EnvironmentTree() {
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
const { data: environments } = useEnvironments(tenantId ?? '');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [starred, setStarred] = useState<Set<string>>(new Set());
|
||||
const [envNodes, setEnvNodes] = useState<Map<string, SidebarTreeNode>>(new Map());
|
||||
|
||||
const handleToggleStar = useCallback((id: string) => {
|
||||
setStarred((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNode = useCallback((node: SidebarTreeNode) => {
|
||||
setEnvNodes((prev) => {
|
||||
const existing = prev.get(node.id);
|
||||
// Avoid infinite re-renders: only update when something meaningful changed.
|
||||
if (
|
||||
existing &&
|
||||
existing.label === node.label &&
|
||||
existing.path === node.path &&
|
||||
existing.children?.length === node.children?.length
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(prev).set(node.id, node);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const envs = environments ?? [];
|
||||
|
||||
// Build the final node list, falling back to env-only nodes until apps load.
|
||||
const nodes: SidebarTreeNode[] = envs.map(
|
||||
(env) =>
|
||||
envNodes.get(env.id) ?? {
|
||||
id: env.id,
|
||||
label: env.displayName,
|
||||
path: `/environments/${env.id}`,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible data-fetchers: one per environment */}
|
||||
{envs.map((env) => (
|
||||
<EnvWithApps key={env.id} env={env} onNode={handleNode} />
|
||||
))}
|
||||
|
||||
<SidebarTree
|
||||
nodes={nodes}
|
||||
selectedPath={location.pathname}
|
||||
isStarred={(id) => starred.has(id)}
|
||||
onToggleStar={handleToggleStar}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
persistKey="env-tree"
|
||||
autoRevealPath={location.pathname}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
166
ui/src/components/Layout.tsx
Normal file
166
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import {
|
||||
AppShell,
|
||||
Sidebar,
|
||||
TopBar,
|
||||
} from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { EnvironmentTree } from './EnvironmentTree';
|
||||
|
||||
// Simple SVG logo mark for the sidebar header
|
||||
function CameleerLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15" />
|
||||
<path
|
||||
d="M7 14c0-2.5 2-4.5 4.5-4.5S16 11.5 16 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="8" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Nav icon helpers
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 4h12M2 8h12M2 12h12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5 8h6M5 5h6M5 11h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ObsIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 8h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M8 4v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M2 13c0-3 2.7-5 6-5s6 2 6 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const sidebar = (
|
||||
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed((c) => !c)}>
|
||||
<Sidebar.Header
|
||||
logo={<CameleerLogo />}
|
||||
title="Cameleer SaaS"
|
||||
onClick={() => navigate('/')}
|
||||
/>
|
||||
|
||||
{/* Dashboard */}
|
||||
<Sidebar.Section
|
||||
icon={<DashboardIcon />}
|
||||
label="Dashboard"
|
||||
open={false}
|
||||
onToggle={() => navigate('/')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* Environments — expandable tree */}
|
||||
<Sidebar.Section
|
||||
icon={<EnvIcon />}
|
||||
label="Environments"
|
||||
open={envSectionOpen}
|
||||
onToggle={() => setEnvSectionOpen((o) => !o)}
|
||||
>
|
||||
<EnvironmentTree />
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* License */}
|
||||
<Sidebar.Section
|
||||
icon={<LicenseIcon />}
|
||||
label="License"
|
||||
open={false}
|
||||
onToggle={() => navigate('/license')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Footer>
|
||||
{/* Link to the observability SPA (external) */}
|
||||
<Sidebar.FooterLink
|
||||
icon={<ObsIcon />}
|
||||
label="View Dashboard"
|
||||
onClick={() => window.open('/dashboard', '_blank', 'noopener')}
|
||||
/>
|
||||
|
||||
{/* User info + logout */}
|
||||
<Sidebar.FooterLink
|
||||
icon={<UserIcon />}
|
||||
label={username ?? 'Account'}
|
||||
onClick={logout}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell sidebar={sidebar}>
|
||||
<TopBar
|
||||
breadcrumb={[]}
|
||||
user={username ? { name: username } : undefined}
|
||||
onLogout={logout}
|
||||
/>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
13
ui/src/components/RequirePermission.tsx
Normal file
13
ui/src/components/RequirePermission.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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}</>;
|
||||
}
|
||||
27
ui/src/hooks/usePermissions.ts
Normal file
27
ui/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
32
ui/src/main.tsx
Normal file
32
ui/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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>,
|
||||
);
|
||||
737
ui/src/pages/AppDetailPage.tsx
Normal file
737
ui/src/pages/AppDetailPage.tsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
FormField,
|
||||
Input,
|
||||
LogViewer,
|
||||
Modal,
|
||||
Spinner,
|
||||
StatusDot,
|
||||
Tabs,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import {
|
||||
useApp,
|
||||
useDeployment,
|
||||
useDeployments,
|
||||
useDeploy,
|
||||
useStop,
|
||||
useRestart,
|
||||
useDeleteApp,
|
||||
useUpdateRouting,
|
||||
useAgentStatus,
|
||||
useObservabilityStatus,
|
||||
useLogs,
|
||||
useCreateApp,
|
||||
} from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import type { DeploymentResponse } from '../types/api';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeploymentRow {
|
||||
id: string;
|
||||
version: number;
|
||||
observedStatus: string;
|
||||
desiredStatus: string;
|
||||
deployedAt: string | null;
|
||||
stoppedAt: string | null;
|
||||
errorMessage: string | null;
|
||||
_raw: DeploymentResponse;
|
||||
}
|
||||
|
||||
// ─── Deployment history columns ───────────────────────────────────────────────
|
||||
|
||||
const deploymentColumns: Column<DeploymentRow>[] = [
|
||||
{
|
||||
key: 'version',
|
||||
header: 'Version',
|
||||
render: (_val, row) => (
|
||||
<span className="font-mono text-sm text-white">v{row.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'observedStatus',
|
||||
header: 'Status',
|
||||
render: (_val, row) => <DeploymentStatusBadge status={row.observedStatus} />,
|
||||
},
|
||||
{
|
||||
key: 'desiredStatus',
|
||||
header: 'Desired',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.desiredStatus} color="primary" variant="outlined" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deployedAt',
|
||||
header: 'Deployed',
|
||||
render: (_val, row) =>
|
||||
row.deployedAt
|
||||
? new Date(row.deployedAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'stoppedAt',
|
||||
header: 'Stopped',
|
||||
render: (_val, row) =>
|
||||
row.stoppedAt
|
||||
? new Date(row.stoppedAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'errorMessage',
|
||||
header: 'Error',
|
||||
render: (_val, row) =>
|
||||
row.errorMessage ? (
|
||||
<span className="text-xs text-red-400 font-mono">{row.errorMessage}</span>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Main page component ──────────────────────────────────────────────────────
|
||||
|
||||
export function AppDetailPage() {
|
||||
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
const { canManageApps, canDeploy } = usePermissions();
|
||||
|
||||
// Active tab
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
// App data
|
||||
const { data: app, isLoading: appLoading } = useApp(envId, appId);
|
||||
|
||||
// Current deployment (auto-polls while BUILDING/STARTING)
|
||||
const { data: currentDeployment } = useDeployment(
|
||||
appId,
|
||||
app?.currentDeploymentId ?? '',
|
||||
);
|
||||
|
||||
// Deployment history
|
||||
const { data: deployments = [] } = useDeployments(appId);
|
||||
|
||||
// Agent and observability status
|
||||
const { data: agentStatus } = useAgentStatus(appId);
|
||||
const { data: obsStatus } = useObservabilityStatus(appId);
|
||||
|
||||
// Log stream filter
|
||||
const [logStream, setLogStream] = useState<string | undefined>(undefined);
|
||||
const { data: logEntries = [] } = useLogs(appId, {
|
||||
limit: 500,
|
||||
stream: logStream,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const deployMutation = useDeploy(appId);
|
||||
const stopMutation = useStop(appId);
|
||||
const restartMutation = useRestart(appId);
|
||||
const deleteMutation = useDeleteApp(envId, appId);
|
||||
const updateRoutingMutation = useUpdateRouting(envId, appId);
|
||||
const reuploadMutation = useCreateApp(envId);
|
||||
|
||||
// Dialog / modal state
|
||||
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
const [reuploadModalOpen, setReuploadModalOpen] = useState(false);
|
||||
|
||||
// Routing form
|
||||
const [portInput, setPortInput] = useState('');
|
||||
|
||||
// Re-upload form
|
||||
const [reuploadFile, setReuploadFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDeploy() {
|
||||
try {
|
||||
await deployMutation.mutateAsync();
|
||||
toast({ title: 'Deployment triggered', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to trigger deployment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stopMutation.mutateAsync();
|
||||
toast({ title: 'App stopped', variant: 'success' });
|
||||
setStopConfirmOpen(false);
|
||||
} catch {
|
||||
toast({ title: 'Failed to stop app', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
try {
|
||||
await restartMutation.mutateAsync();
|
||||
toast({ title: 'App restarting', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to restart app', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
toast({ title: 'App deleted', variant: 'success' });
|
||||
navigate(`/environments/${envId}`);
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete app', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateRouting(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const port = portInput.trim() === '' ? null : parseInt(portInput, 10);
|
||||
if (port !== null && (isNaN(port) || port < 1 || port > 65535)) {
|
||||
toast({ title: 'Invalid port number', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateRoutingMutation.mutateAsync({ exposedPort: port });
|
||||
toast({ title: 'Routing updated', variant: 'success' });
|
||||
setRoutingModalOpen(false);
|
||||
} catch {
|
||||
toast({ title: 'Failed to update routing', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function openRoutingModal() {
|
||||
setPortInput(app?.exposedPort != null ? String(app.exposedPort) : '');
|
||||
setRoutingModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleReupload(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!reuploadFile) return;
|
||||
const formData = new FormData();
|
||||
formData.append('jar', reuploadFile);
|
||||
if (app?.slug) formData.append('slug', app.slug);
|
||||
if (app?.displayName) formData.append('displayName', app.displayName);
|
||||
try {
|
||||
await reuploadMutation.mutateAsync(formData);
|
||||
toast({ title: 'JAR uploaded', variant: 'success' });
|
||||
setReuploadModalOpen(false);
|
||||
setReuploadFile(null);
|
||||
} catch {
|
||||
toast({ title: 'Failed to upload JAR', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const deploymentRows: DeploymentRow[] = deployments.map((d) => ({
|
||||
id: d.id,
|
||||
version: d.version,
|
||||
observedStatus: d.observedStatus,
|
||||
desiredStatus: d.desiredStatus,
|
||||
deployedAt: d.deployedAt,
|
||||
stoppedAt: d.stoppedAt,
|
||||
errorMessage: d.errorMessage,
|
||||
_raw: d,
|
||||
}));
|
||||
|
||||
// Map API LogEntry to design system LogEntry
|
||||
const dsLogEntries: DSLogEntry[] = logEntries.map((entry) => ({
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.stream === 'stderr' ? ('error' as const) : ('info' as const),
|
||||
message: entry.message,
|
||||
}));
|
||||
|
||||
// Agent state → StatusDot variant
|
||||
function agentDotVariant(): 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running' {
|
||||
if (!agentStatus?.registered) return 'dead';
|
||||
switch (agentStatus.state) {
|
||||
case 'CONNECTED': return 'live';
|
||||
case 'DISCONNECTED': return 'stale';
|
||||
default: return 'stale';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Loading / not-found states ────────────────────────────────────────────
|
||||
|
||||
if (appLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="App not found"
|
||||
description="The requested app does not exist or you do not have access."
|
||||
action={
|
||||
<Button variant="secondary" onClick={() => navigate(`/environments/${envId}`)}>
|
||||
Back to Environment
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Breadcrumb ─────────────────────────────────────────────────────────────
|
||||
|
||||
const breadcrumb = (
|
||||
<nav className="flex items-center gap-1.5 text-sm text-white/50 mb-6">
|
||||
<Link to="/" className="hover:text-white/80 transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link to="/environments" className="hover:text-white/80 transition-colors">Environments</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/environments/${envId}`} className="hover:text-white/80 transition-colors">
|
||||
{envId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white/90">{app.displayName}</span>
|
||||
</nav>
|
||||
);
|
||||
|
||||
// ─── Tabs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', value: 'overview' },
|
||||
{ label: 'Deployments', value: 'deployments' },
|
||||
{ label: 'Logs', value: 'logs' },
|
||||
];
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumb}
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{app.displayName}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge label={app.slug} color="primary" variant="outlined" />
|
||||
{app.jarOriginalFilename && (
|
||||
<span className="text-xs text-white/50 font-mono">{app.jarOriginalFilename}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{/* ── Tab: Overview ── */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status card */}
|
||||
<Card title="Current Deployment">
|
||||
{!app.currentDeploymentId ? (
|
||||
<div className="py-4 text-center text-white/50">No deployments yet</div>
|
||||
) : !currentDeployment ? (
|
||||
<div className="py-4 text-center">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-6 py-2">
|
||||
<div>
|
||||
<div className="text-xs text-white/50 mb-1">Version</div>
|
||||
<span className="font-mono font-semibold text-white">
|
||||
v{currentDeployment.version}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/50 mb-1">Status</div>
|
||||
<DeploymentStatusBadge status={currentDeployment.observedStatus} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/50 mb-1">Image</div>
|
||||
<span className="font-mono text-xs text-white/70">
|
||||
{currentDeployment.imageRef}
|
||||
</span>
|
||||
</div>
|
||||
{currentDeployment.deployedAt && (
|
||||
<div>
|
||||
<div className="text-xs text-white/50 mb-1">Deployed</div>
|
||||
<span className="text-sm text-white/70">
|
||||
{new Date(currentDeployment.deployedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{currentDeployment.errorMessage && (
|
||||
<div className="w-full">
|
||||
<div className="text-xs text-white/50 mb-1">Error</div>
|
||||
<span className="text-xs text-red-400 font-mono">
|
||||
{currentDeployment.errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Action bar */}
|
||||
<Card title="Actions">
|
||||
<div className="flex flex-wrap gap-2 py-2">
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={deployMutation.isPending}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={restartMutation.isPending}
|
||||
onClick={handleRestart}
|
||||
disabled={!app.currentDeploymentId}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setStopConfirmOpen(true)}
|
||||
disabled={!app.currentDeploymentId}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReuploadFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setReuploadModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Re-upload JAR
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
Delete App
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Agent status card */}
|
||||
<Card title="Agent Status">
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot variant={agentDotVariant()} pulse={agentStatus?.state === 'CONNECTED'} />
|
||||
<span className="text-sm text-white/80">
|
||||
{agentStatus?.registered ? 'Registered' : 'Not registered'}
|
||||
</span>
|
||||
{agentStatus?.state && (
|
||||
<Badge
|
||||
label={agentStatus.state}
|
||||
color={agentStatus.state === 'CONNECTED' ? 'success' : 'auto'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agentStatus?.lastHeartbeat && (
|
||||
<div className="text-xs text-white/50">
|
||||
Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentStatus?.routeIds && agentStatus.routeIds.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-white/50 mb-1">Routes</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agentStatus.routeIds.map((rid) => (
|
||||
<Badge key={rid} label={rid} color="primary" variant="outlined" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obsStatus && (
|
||||
<div className="flex flex-wrap gap-4 pt-1">
|
||||
<span className="text-xs text-white/50">
|
||||
Traces:{' '}
|
||||
<span className={obsStatus.hasTraces ? 'text-green-400' : 'text-white/30'}>
|
||||
{obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-white/50">
|
||||
Metrics:{' '}
|
||||
<span className={obsStatus.hasMetrics ? 'text-green-400' : 'text-white/30'}>
|
||||
{obsStatus.hasMetrics ? 'yes' : 'none'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-white/50">
|
||||
Diagrams:{' '}
|
||||
<span className={obsStatus.hasDiagrams ? 'text-green-400' : 'text-white/30'}>
|
||||
{obsStatus.hasDiagrams ? 'yes' : 'none'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
View in Dashboard →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Routing card */}
|
||||
<Card title="Routing">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-1">
|
||||
{app.exposedPort ? (
|
||||
<>
|
||||
<div className="text-xs text-white/50">Port</div>
|
||||
<span className="font-mono text-white">{app.exposedPort}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-white/40">No port configured</span>
|
||||
)}
|
||||
{app.routeUrl && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-white/50 mb-0.5">Route URL</div>
|
||||
<a
|
||||
href={app.routeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 font-mono transition-colors"
|
||||
>
|
||||
{app.routeUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="secondary" size="sm" onClick={openRoutingModal}>
|
||||
Edit Routing
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Deployments ── */}
|
||||
{activeTab === 'deployments' && (
|
||||
<Card title="Deployment History">
|
||||
{deploymentRows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No deployments yet"
|
||||
description="Deploy your app to see history here."
|
||||
/>
|
||||
) : (
|
||||
<DataTable<DeploymentRow>
|
||||
columns={deploymentColumns}
|
||||
data={deploymentRows}
|
||||
pageSize={20}
|
||||
rowAccent={(row) =>
|
||||
row.observedStatus === 'FAILED' ? 'error' : undefined
|
||||
}
|
||||
flush
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Logs ── */}
|
||||
{activeTab === 'logs' && (
|
||||
<Card title="Container Logs">
|
||||
<div className="space-y-3">
|
||||
{/* Stream filter */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ label: 'All', value: undefined },
|
||||
{ label: 'stdout', value: 'stdout' },
|
||||
{ label: 'stderr', value: 'stderr' },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={String(opt.value)}
|
||||
variant={logStream === opt.value ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setLogStream(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsLogEntries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No logs available"
|
||||
description="Logs will appear here once the app is running."
|
||||
/>
|
||||
) : (
|
||||
<LogViewer entries={dsLogEntries} maxHeight={500} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Dialogs / Modals ── */}
|
||||
|
||||
{/* Stop confirmation */}
|
||||
<ConfirmDialog
|
||||
open={stopConfirmOpen}
|
||||
onClose={() => setStopConfirmOpen(false)}
|
||||
onConfirm={handleStop}
|
||||
title="Stop App"
|
||||
message={`Are you sure you want to stop "${app.displayName}"?`}
|
||||
confirmText="Stop"
|
||||
confirmLabel="Stop"
|
||||
cancelLabel="Cancel"
|
||||
variant="warning"
|
||||
loading={stopMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete App"
|
||||
message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Routing modal */}
|
||||
<Modal
|
||||
open={routingModalOpen}
|
||||
onClose={() => setRoutingModalOpen(false)}
|
||||
title="Edit Routing"
|
||||
size="sm"
|
||||
>
|
||||
<form onSubmit={handleUpdateRouting} className="space-y-4">
|
||||
<FormField
|
||||
label="Exposed Port"
|
||||
htmlFor="exposed-port"
|
||||
hint="Leave empty to remove the exposed port."
|
||||
>
|
||||
<Input
|
||||
id="exposed-port"
|
||||
type="number"
|
||||
value={portInput}
|
||||
onChange={(e) => setPortInput(e.target.value)}
|
||||
placeholder="e.g. 8080"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setRoutingModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={updateRoutingMutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Re-upload JAR modal */}
|
||||
<Modal
|
||||
open={reuploadModalOpen}
|
||||
onClose={() => setReuploadModalOpen(false)}
|
||||
title="Re-upload JAR"
|
||||
size="sm"
|
||||
>
|
||||
<form onSubmit={handleReupload} className="space-y-4">
|
||||
<FormField label="JAR File" htmlFor="reupload-jar" required>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="reupload-jar"
|
||||
type="file"
|
||||
accept=".jar"
|
||||
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||
onChange={(e) => setReuploadFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setReuploadModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={reuploadMutation.isPending}
|
||||
disabled={!reuploadFile}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
ui/src/pages/DashboardPage.tsx
Normal file
221
ui/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
KpiStrip,
|
||||
Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useTenant, useEnvironments, useApps } from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import type { EnvironmentResponse, AppResponse } from '../types/api';
|
||||
|
||||
// Helper: fetches apps for one environment and reports data upward via effect
|
||||
function EnvApps({
|
||||
environment,
|
||||
onData,
|
||||
}: {
|
||||
environment: EnvironmentResponse;
|
||||
onData: (envId: string, apps: AppResponse[]) => void;
|
||||
}) {
|
||||
const { data } = useApps(environment.id);
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onData(environment.id, data);
|
||||
}
|
||||
}, [data, environment.id, onData]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||
switch (tier?.toLowerCase()) {
|
||||
case 'enterprise': return 'success';
|
||||
case 'pro': return 'primary';
|
||||
case 'starter': return 'warning';
|
||||
default: return 'primary';
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
|
||||
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
|
||||
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||
|
||||
// Collect apps per environment using a ref-like approach via state + callback
|
||||
const [appsByEnv, setAppsByEnv] = useState<Record<string, AppResponse[]>>({});
|
||||
|
||||
const handleAppsData = useCallback((envId: string, apps: AppResponse[]) => {
|
||||
setAppsByEnv((prev) => {
|
||||
if (prev[envId] === apps) return prev; // stable reference, no update
|
||||
return { ...prev, [envId]: apps };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allApps = Object.values(appsByEnv).flat();
|
||||
const runningApps = allApps.filter((a) => a.currentDeploymentId !== null);
|
||||
// "Failed" is apps that have a previous deployment but no current (stopped) — approximate heuristic
|
||||
const failedApps = allApps.filter(
|
||||
(a) => a.currentDeploymentId === null && a.previousDeploymentId !== null,
|
||||
);
|
||||
|
||||
const isLoading = tenantLoading || envsLoading;
|
||||
|
||||
const kpiItems = [
|
||||
{
|
||||
label: 'Environments',
|
||||
value: environments?.length ?? 0,
|
||||
subtitle: 'isolated runtime contexts',
|
||||
},
|
||||
{
|
||||
label: 'Total Apps',
|
||||
value: allApps.length,
|
||||
subtitle: 'across all environments',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
value: runningApps.length,
|
||||
trend: {
|
||||
label: 'active deployments',
|
||||
variant: 'success' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Stopped',
|
||||
value: failedApps.length,
|
||||
trend: failedApps.length > 0
|
||||
? { label: 'need attention', variant: 'warning' as const }
|
||||
: { label: 'none', variant: 'muted' as const },
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No tenant associated"
|
||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Tenant Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{tenant?.name ?? tenantId}
|
||||
</h1>
|
||||
{tenant?.tier && (
|
||||
<Badge
|
||||
label={tenant.tier.toUpperCase()}
|
||||
color={tierColor(tenant.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/environments/new')}
|
||||
>
|
||||
New Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
View Observability Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Environments overview */}
|
||||
{environments && environments.length > 0 ? (
|
||||
<Card title="Environments">
|
||||
{/* Render hidden data-fetchers for each environment */}
|
||||
{environments.map((env) => (
|
||||
<EnvApps key={env.id} environment={env} onData={handleAppsData} />
|
||||
))}
|
||||
<div className="divide-y divide-white/10">
|
||||
{environments.map((env) => {
|
||||
const envApps = appsByEnv[env.id] ?? [];
|
||||
const envRunning = envApps.filter((a) => a.currentDeploymentId !== null).length;
|
||||
return (
|
||||
<div
|
||||
key={env.id}
|
||||
className="flex items-center justify-between py-3 first:pt-0 last:pb-0 cursor-pointer hover:bg-white/5 px-2 rounded"
|
||||
onClick={() => navigate(`/environments/${env.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{env.displayName}
|
||||
</span>
|
||||
<Badge
|
||||
label={env.slug}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-white/60">
|
||||
<span>{envApps.length} apps</span>
|
||||
<span className="text-green-400">{envRunning} running</span>
|
||||
<Badge
|
||||
label={env.status}
|
||||
color={env.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No environments yet"
|
||||
description="Create your first environment to get started deploying Camel applications."
|
||||
action={
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="primary" onClick={() => navigate('/environments/new')}>
|
||||
Create Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent deployments placeholder */}
|
||||
<Card title="Recent Deployments">
|
||||
{allApps.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No deployments yet"
|
||||
description="Deploy your first app to see deployment history here."
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-white/60">
|
||||
Select an app from an environment to view its deployment history.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
318
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
318
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
FormField,
|
||||
InlineEdit,
|
||||
Input,
|
||||
Modal,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import {
|
||||
useEnvironments,
|
||||
useUpdateEnvironment,
|
||||
useDeleteEnvironment,
|
||||
useApps,
|
||||
useCreateApp,
|
||||
} from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||
import type { AppResponse } from '../types/api';
|
||||
|
||||
interface AppTableRow {
|
||||
id: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
deploymentStatus: string;
|
||||
updatedAt: string;
|
||||
_raw: AppResponse;
|
||||
}
|
||||
|
||||
const appColumns: Column<AppTableRow>[] = [
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Name',
|
||||
render: (_val, row) => (
|
||||
<span className="font-medium text-white">{row.displayName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slug',
|
||||
header: 'Slug',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deploymentStatus',
|
||||
header: 'Status',
|
||||
render: (_val, row) =>
|
||||
row._raw.currentDeploymentId ? (
|
||||
<DeploymentStatusBadge status={row.deploymentStatus} />
|
||||
) : (
|
||||
<Badge label="Not deployed" color="auto" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
header: 'Last Updated',
|
||||
render: (_val, row) =>
|
||||
new Date(row.updatedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvironmentDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { envId } = useParams<{ envId: string }>();
|
||||
const { toast } = useToast();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
|
||||
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||
const environment = environments?.find((e) => e.id === envId);
|
||||
|
||||
const { data: apps, isLoading: appsLoading } = useApps(envId ?? '');
|
||||
|
||||
const updateMutation = useUpdateEnvironment(tenantId ?? '', envId ?? '');
|
||||
const deleteMutation = useDeleteEnvironment(tenantId ?? '', envId ?? '');
|
||||
const createAppMutation = useCreateApp(envId ?? '');
|
||||
|
||||
// New app modal
|
||||
const [newAppOpen, setNewAppOpen] = useState(false);
|
||||
const [appSlug, setAppSlug] = useState('');
|
||||
const [appDisplayName, setAppDisplayName] = useState('');
|
||||
const [jarFile, setJarFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Delete confirm
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
function openNewApp() {
|
||||
setAppSlug('');
|
||||
setAppDisplayName('');
|
||||
setJarFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setNewAppOpen(true);
|
||||
}
|
||||
|
||||
function closeNewApp() {
|
||||
setNewAppOpen(false);
|
||||
}
|
||||
|
||||
async function handleCreateApp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!appSlug.trim() || !appDisplayName.trim()) return;
|
||||
const formData = new FormData();
|
||||
formData.append('slug', appSlug.trim());
|
||||
formData.append('displayName', appDisplayName.trim());
|
||||
if (jarFile) {
|
||||
formData.append('jar', jarFile);
|
||||
}
|
||||
try {
|
||||
await createAppMutation.mutateAsync(formData);
|
||||
toast({ title: 'App created', variant: 'success' });
|
||||
closeNewApp();
|
||||
} catch {
|
||||
toast({ title: 'Failed to create app', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEnvironment() {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
toast({ title: 'Environment deleted', variant: 'success' });
|
||||
navigate('/environments');
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename(value: string) {
|
||||
if (!value.trim() || value === environment?.displayName) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ displayName: value.trim() });
|
||||
toast({ title: 'Environment renamed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to rename environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = envsLoading || appsLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Environment not found"
|
||||
description="The requested environment does not exist or you do not have access."
|
||||
action={
|
||||
<Button variant="secondary" onClick={() => navigate('/environments')}>
|
||||
Back to Environments
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tableData: AppTableRow[] = (apps ?? []).map((app) => ({
|
||||
id: app.id,
|
||||
displayName: app.displayName,
|
||||
slug: app.slug,
|
||||
deploymentStatus: app.currentDeploymentId ? 'RUNNING' : 'STOPPED',
|
||||
updatedAt: app.updatedAt,
|
||||
_raw: app,
|
||||
}));
|
||||
|
||||
const hasApps = (apps?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RequirePermission
|
||||
permission="apps:manage"
|
||||
fallback={
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{environment.displayName}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={environment.displayName}
|
||||
onSave={handleRename}
|
||||
placeholder="Environment name"
|
||||
/>
|
||||
</RequirePermission>
|
||||
<Badge label={environment.slug} color="primary" variant="outlined" />
|
||||
<Badge
|
||||
label={environment.status}
|
||||
color={environment.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button variant="primary" size="sm" onClick={openNewApp}>
|
||||
New App
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={hasApps}
|
||||
title={hasApps ? 'Remove all apps before deleting this environment' : undefined}
|
||||
>
|
||||
Delete Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps table */}
|
||||
{tableData.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No apps yet"
|
||||
description="Deploy your first Camel application to this environment."
|
||||
action={
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button variant="primary" onClick={openNewApp}>
|
||||
New App
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card title="Apps">
|
||||
<DataTable<AppTableRow>
|
||||
columns={appColumns}
|
||||
data={tableData}
|
||||
onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)}
|
||||
flush
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New App Modal */}
|
||||
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
|
||||
<form onSubmit={handleCreateApp} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="app-slug" required>
|
||||
<Input
|
||||
id="app-slug"
|
||||
value={appSlug}
|
||||
onChange={(e) => setAppSlug(e.target.value)}
|
||||
placeholder="e.g. order-router"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="app-display-name" required>
|
||||
<Input
|
||||
id="app-display-name"
|
||||
value={appDisplayName}
|
||||
onChange={(e) => setAppDisplayName(e.target.value)}
|
||||
placeholder="e.g. Order Router"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="JAR File" htmlFor="app-jar">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="app-jar"
|
||||
type="file"
|
||||
accept=".jar"
|
||||
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||
onChange={(e) => setJarFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createAppMutation.isPending}
|
||||
disabled={!appSlug.trim() || !appDisplayName.trim()}
|
||||
>
|
||||
Create App
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDeleteEnvironment}
|
||||
title="Delete Environment"
|
||||
message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
ui/src/pages/EnvironmentsPage.tsx
Normal file
193
ui/src/pages/EnvironmentsPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import type { EnvironmentResponse } from '../types/api';
|
||||
|
||||
interface TableRow {
|
||||
id: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
_raw: EnvironmentResponse;
|
||||
}
|
||||
|
||||
const columns: Column<TableRow>[] = [
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Name',
|
||||
render: (_val, row) => (
|
||||
<span className="font-medium text-white">{row.displayName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slug',
|
||||
header: 'Slug',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (_val, row) => (
|
||||
<Badge
|
||||
label={row.status}
|
||||
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (_val, row) =>
|
||||
new Date(row.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
|
||||
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
||||
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [slug, setSlug] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
|
||||
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
||||
id: env.id,
|
||||
displayName: env.displayName,
|
||||
slug: env.slug,
|
||||
status: env.status,
|
||||
createdAt: env.createdAt,
|
||||
_raw: env,
|
||||
}));
|
||||
|
||||
function openModal() {
|
||||
setSlug('');
|
||||
setDisplayName('');
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!slug.trim() || !displayName.trim()) return;
|
||||
try {
|
||||
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
||||
toast({ title: 'Environment created', variant: 'success' });
|
||||
closeModal();
|
||||
} catch {
|
||||
toast({ title: 'Failed to create environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-white">Environments</h1>
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="primary" size="sm" onClick={openModal}>
|
||||
Create Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Table / empty state */}
|
||||
{tableData.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No environments yet"
|
||||
description="Create your first environment to start deploying Camel applications."
|
||||
action={
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="primary" onClick={openModal}>
|
||||
Create Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<DataTable<TableRow>
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
onRowClick={(row) => navigate(`/environments/${row.id}`)}
|
||||
flush
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Environment Modal */}
|
||||
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="env-slug" required>
|
||||
<Input
|
||||
id="env-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="env-display-name" required>
|
||||
<Input
|
||||
id="env-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Production"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createMutation.isPending}
|
||||
disabled={!slug.trim() || !displayName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
ui/src/pages/LicensePage.tsx
Normal file
184
ui/src/pages/LicensePage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useLicense } from '../api/hooks';
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
topology: 'Topology',
|
||||
lineage: 'Lineage',
|
||||
correlation: 'Correlation',
|
||||
debugger: 'Debugger',
|
||||
replay: 'Replay',
|
||||
};
|
||||
|
||||
const LIMIT_LABELS: Record<string, string> = {
|
||||
maxAgents: 'Max Agents',
|
||||
retentionDays: 'Retention Days',
|
||||
maxEnvironments: 'Max Environments',
|
||||
};
|
||||
|
||||
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||
switch (tier?.toUpperCase()) {
|
||||
case 'BUSINESS': return 'success';
|
||||
case 'HIGH': return 'primary';
|
||||
case 'MID': return 'warning';
|
||||
case 'LOW': return 'error';
|
||||
default: return 'primary';
|
||||
}
|
||||
}
|
||||
|
||||
function daysRemaining(expiresAt: string): number {
|
||||
const now = Date.now();
|
||||
const exp = new Date(expiresAt).getTime();
|
||||
return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
export function LicensePage() {
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
||||
const [tokenExpanded, setTokenExpanded] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No tenant associated"
|
||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !license) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="License unavailable"
|
||||
description="Unable to load license information. Please try again later."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const days = daysRemaining(license.expiresAt);
|
||||
const isExpiringSoon = days <= 30;
|
||||
const isExpired = days === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-white">License</h1>
|
||||
<Badge
|
||||
label={license.tier.toUpperCase()}
|
||||
color={tierColor(license.tier)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry info */}
|
||||
<Card title="Validity">
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Issued</span>
|
||||
<span className="text-white">
|
||||
{new Date(license.issuedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Expires</span>
|
||||
<span className="text-white">{expDate}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Days remaining</span>
|
||||
<Badge
|
||||
label={isExpired ? 'Expired' : `${days} days`}
|
||||
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Feature matrix */}
|
||||
<Card title="Features">
|
||||
<div className="divide-y divide-white/10">
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => {
|
||||
const enabled = license.features[key] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="text-sm text-white">{label}</span>
|
||||
<Badge
|
||||
label={enabled ? 'Enabled' : 'Disabled'}
|
||||
color={enabled ? 'success' : 'error'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Limits */}
|
||||
<Card title="Limits">
|
||||
<div className="divide-y divide-white/10">
|
||||
{Object.entries(LIMIT_LABELS).map(([key, label]) => {
|
||||
const value = license.limits[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="text-sm text-white/60">{label}</span>
|
||||
<span className="text-sm font-mono text-white">
|
||||
{value !== undefined ? value : '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* License token */}
|
||||
<Card title="License Token">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-white/60">
|
||||
Use this token when registering Cameleer agents with your tenant.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
|
||||
onClick={() => setTokenExpanded((v) => !v)}
|
||||
>
|
||||
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||
</button>
|
||||
{tokenExpanded && (
|
||||
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
|
||||
<code className="text-xs font-mono text-white/80 break-all">
|
||||
{license.token}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
ui/src/router.tsx
Normal file
39
ui/src/router.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
85
ui/src/types/api.ts
Normal file
85
ui/src/types/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
18
ui/tsconfig.json
Normal file
18
ui/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
19
ui/vite.config.ts
Normal file
19
ui/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user