Compare commits
27 Commits
feat/phase
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e325c4d2c0 | ||
|
|
4c8c8efbe5 | ||
|
|
f6d3627abc | ||
|
|
fe786790e1 | ||
|
|
5eac48ad72 | ||
|
|
02019e9347 | ||
|
|
91a4235223 | ||
|
|
e725669aef | ||
|
|
d572926010 | ||
|
|
e33818cc74 | ||
|
|
146dbccc6e | ||
|
|
600985c913 | ||
| 7aa331d73c | |||
|
|
9b1643c1ee | ||
|
|
9f8d0f43ab | ||
|
|
43cd2d012f | ||
|
|
210da55e7a | ||
|
|
08b87edd6e | ||
|
|
024780c01e | ||
|
|
d25849d665 | ||
|
|
b0275bcf64 | ||
|
|
f8d80eaf79 | ||
|
|
41629f3290 | ||
|
|
b78dfa9a7b | ||
|
|
d81ce2b697 | ||
|
|
cbf7d5c60f | ||
| 956eb13dd6 |
@@ -27,3 +27,4 @@ DOMAIN=localhost
|
|||||||
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
||||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
||||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
CAMELEER_CONTAINER_CPU_SHARES=512
|
||||||
|
CAMELEER_TENANT_SLUG=default
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ jobs:
|
|||||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||||
restore-keys: ${{ runner.os }}-maven-
|
restore-keys: ${{ runner.os }}-maven-
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Build and Test (unit tests only)
|
- name: Build and Test (unit tests only)
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify -B
|
mvn clean verify -B
|
||||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Build, Test and Analyze
|
- name: Build, Test and Analyze
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify sonar:sonar --batch-mode
|
mvn clean verify sonar:sonar --batch-mode
|
||||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
||||||
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
|
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
|
||||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||||
-Dsonar.projectKey=cameleer-saas
|
-Dsonar.projectKey=cameleer-saas
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,11 +1,19 @@
|
|||||||
# Dockerfile
|
# 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
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY .mvn/ .mvn/
|
COPY .mvn/ .mvn/
|
||||||
COPY mvnw pom.xml ./
|
COPY mvnw pom.xml ./
|
||||||
RUN ./mvnw dependency:go-offline -B
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
RUN ./mvnw package -DskipTests -B
|
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
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
389
HOWTO.md
Normal file
389
HOWTO.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# Cameleer SaaS -- How to Install, Start & Bootstrap
|
||||||
|
|
||||||
|
## Quick Start (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone
|
||||||
|
git clone https://gitea.siegeln.net/cameleer/cameleer-saas.git
|
||||||
|
cd cameleer-saas
|
||||||
|
|
||||||
|
# 2. Create environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. Generate Ed25519 key pair
|
||||||
|
mkdir -p keys
|
||||||
|
ssh-keygen -t ed25519 -f keys/ed25519 -N ""
|
||||||
|
mv keys/ed25519 keys/ed25519.key
|
||||||
|
|
||||||
|
# 4. Start the stack
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 5. Wait for services to be ready (~30s)
|
||||||
|
docker compose logs -f cameleer-saas --since 10s
|
||||||
|
# Look for: "Started CameleerSaasApplication"
|
||||||
|
|
||||||
|
# 6. Verify
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
# {"status":"UP"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Desktop (Windows/Mac) or Docker Engine 24+ (Linux)
|
||||||
|
- Git
|
||||||
|
- `curl` or any HTTP client (for testing)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The platform runs as a Docker Compose stack with 6 services:
|
||||||
|
|
||||||
|
| Service | Image | Port | Purpose |
|
||||||
|
|---------|-------|------|---------|
|
||||||
|
| **traefik** | traefik:v3 | 80, 443 | Reverse proxy, TLS, routing |
|
||||||
|
| **postgres** | postgres:16-alpine | 5432* | Platform database + Logto database |
|
||||||
|
| **logto** | ghcr.io/logto-io/logto | 3001*, 3002* | Identity provider (OIDC) |
|
||||||
|
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server |
|
||||||
|
| **cameleer3-server** | cameleer3-server:latest | 8081 | Observability backend |
|
||||||
|
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
|
||||||
|
|
||||||
|
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Environment Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set at minimum:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Change in production
|
||||||
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
|
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
|
||||||
|
CAMELEER_TENANT_SLUG=<your-tenant-slug> # e.g., "acme" — tags all observability data
|
||||||
|
|
||||||
|
# Logto M2M credentials (get from Logto admin console after first boot)
|
||||||
|
LOGTO_M2M_CLIENT_ID=
|
||||||
|
LOGTO_M2M_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ed25519 Keys
|
||||||
|
|
||||||
|
The platform uses Ed25519 keys for license signing and machine token verification.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p keys
|
||||||
|
ssh-keygen -t ed25519 -f keys/ed25519 -N ""
|
||||||
|
mv keys/ed25519 keys/ed25519.key
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `keys/ed25519.key` (private) and `keys/ed25519.pub` (public). The keys directory is mounted read-only into the cameleer-saas container.
|
||||||
|
|
||||||
|
If no key files are configured, the platform generates ephemeral keys on startup (suitable for development only -- keys change on every restart).
|
||||||
|
|
||||||
|
### 3. Start the Stack
|
||||||
|
|
||||||
|
**Development** (ports exposed for direct access):
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production** (traffic routed through Traefik only):
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
|
||||||
|
# Check all containers are running
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrapping
|
||||||
|
|
||||||
|
### First-Time Logto Setup
|
||||||
|
|
||||||
|
On first boot, Logto seeds its database automatically. Access the admin console to configure it:
|
||||||
|
|
||||||
|
1. Open http://localhost:3002 (Logto admin console)
|
||||||
|
2. Complete the initial setup wizard
|
||||||
|
3. Create a **Machine-to-Machine** application:
|
||||||
|
- Go to Applications > Create Application > Machine-to-Machine
|
||||||
|
- Note the **App ID** and **App Secret**
|
||||||
|
- Assign the **Logto Management API** resource with all scopes
|
||||||
|
4. Update `.env`:
|
||||||
|
```
|
||||||
|
LOGTO_M2M_CLIENT_ID=<app-id>
|
||||||
|
LOGTO_M2M_CLIENT_SECRET=<app-secret>
|
||||||
|
```
|
||||||
|
5. Restart cameleer-saas: `docker compose restart cameleer-saas`
|
||||||
|
|
||||||
|
### Create Your First Tenant
|
||||||
|
|
||||||
|
With a Logto user token (obtained via OIDC login flow):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="<your-logto-jwt>"
|
||||||
|
|
||||||
|
# Create tenant
|
||||||
|
curl -X POST http://localhost:8080/api/tenants \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "My Company", "slug": "my-company", "tier": "MID"}'
|
||||||
|
|
||||||
|
# A "default" environment is auto-created with the tenant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate a License
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TENANT_ID="<uuid-from-above>"
|
||||||
|
|
||||||
|
curl -X POST "http://localhost:8080/api/tenants/$TENANT_ID/license" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy a Camel Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List environments
|
||||||
|
curl "http://localhost:8080/api/tenants/$TENANT_ID/environments" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
ENV_ID="<default-environment-uuid>"
|
||||||
|
|
||||||
|
# Upload JAR and create app
|
||||||
|
curl -X POST "http://localhost:8080/api/environments/$ENV_ID/apps" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F 'metadata={"slug":"order-service","displayName":"Order Service"};type=application/json' \
|
||||||
|
-F "file=@/path/to/your-camel-app.jar"
|
||||||
|
|
||||||
|
APP_ID="<app-uuid-from-response>"
|
||||||
|
|
||||||
|
# Deploy (async -- returns 202 with deployment ID)
|
||||||
|
curl -X POST "http://localhost:8080/api/apps/$APP_ID/deploy" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
DEPLOYMENT_ID="<deployment-uuid>"
|
||||||
|
|
||||||
|
# Poll deployment status
|
||||||
|
curl "http://localhost:8080/api/apps/$APP_ID/deployments/$DEPLOYMENT_ID" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
# Status transitions: BUILDING -> STARTING -> RUNNING (or FAILED)
|
||||||
|
|
||||||
|
# View container logs
|
||||||
|
curl "http://localhost:8080/api/apps/$APP_ID/logs?limit=50" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Stop the app
|
||||||
|
curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Inbound HTTP Routing
|
||||||
|
|
||||||
|
If your Camel app exposes a REST endpoint, you can make it reachable from outside the stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set the port your app listens on (e.g., 8080 for Spring Boot)
|
||||||
|
curl -X PATCH "http://localhost:8080/api/environments/$ENV_ID/apps/$APP_ID/routing" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"exposedPort": 8080}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app is now reachable at `http://{app-slug}.{env-slug}.{tenant-slug}.{domain}` (e.g., `http://order-service.default.my-company.localhost`). Traefik routes traffic automatically.
|
||||||
|
|
||||||
|
To disable routing, set `exposedPort` to `null`.
|
||||||
|
|
||||||
|
### View the Observability Dashboard
|
||||||
|
|
||||||
|
The cameleer3-server React SPA dashboard is available at:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
This shows execution traces, route topology graphs, metrics, and logs for all deployed apps. Authentication is required (Logto OIDC token via forward-auth).
|
||||||
|
|
||||||
|
### Check Agent & Observability Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Is the agent registered with cameleer3-server?
|
||||||
|
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
|
||||||
|
|
||||||
|
# Is the app producing observability data?
|
||||||
|
curl "http://localhost:8080/api/apps/$APP_ID/observability-status" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
# Returns: hasTraces, lastTraceAt, traceCount24h
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Tenants
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/tenants` | Create tenant |
|
||||||
|
| GET | `/api/tenants/{id}` | Get tenant |
|
||||||
|
| GET | `/api/tenants/by-slug/{slug}` | Get tenant by slug |
|
||||||
|
|
||||||
|
### Licensing
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/tenants/{tid}/license` | Generate license |
|
||||||
|
| GET | `/api/tenants/{tid}/license` | Get active license |
|
||||||
|
|
||||||
|
### Environments
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/tenants/{tid}/environments` | Create environment |
|
||||||
|
| GET | `/api/tenants/{tid}/environments` | List environments |
|
||||||
|
| GET | `/api/tenants/{tid}/environments/{eid}` | Get environment |
|
||||||
|
| PATCH | `/api/tenants/{tid}/environments/{eid}` | Rename environment |
|
||||||
|
| DELETE | `/api/tenants/{tid}/environments/{eid}` | Delete environment |
|
||||||
|
|
||||||
|
### Apps
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/environments/{eid}/apps` | Create app + upload JAR |
|
||||||
|
| GET | `/api/environments/{eid}/apps` | List apps |
|
||||||
|
| GET | `/api/environments/{eid}/apps/{aid}` | Get app |
|
||||||
|
| PUT | `/api/environments/{eid}/apps/{aid}/jar` | Re-upload JAR |
|
||||||
|
| PATCH | `/api/environments/{eid}/apps/{aid}/routing` | Set/clear exposed port |
|
||||||
|
| DELETE | `/api/environments/{eid}/apps/{aid}` | Delete app |
|
||||||
|
|
||||||
|
### Deployments
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/apps/{aid}/deploy` | Deploy app (async, 202) |
|
||||||
|
| GET | `/api/apps/{aid}/deployments` | Deployment history |
|
||||||
|
| GET | `/api/apps/{aid}/deployments/{did}` | Get deployment status |
|
||||||
|
| POST | `/api/apps/{aid}/stop` | Stop current deployment |
|
||||||
|
| POST | `/api/apps/{aid}/restart` | Restart app |
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/apps/{aid}/logs` | Query container logs |
|
||||||
|
|
||||||
|
Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream` (stdout/stderr/both)
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/apps/{aid}/agent-status` | Agent registration status |
|
||||||
|
| GET | `/api/apps/{aid}/observability-status` | Trace/metrics data health |
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/dashboard` | cameleer3-server observability dashboard (forward-auth protected) |
|
||||||
|
|
||||||
|
### Health
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/actuator/health` | Health check (public) |
|
||||||
|
| GET | `/api/health/secured` | Authenticated health check |
|
||||||
|
|
||||||
|
## Tier Limits
|
||||||
|
|
||||||
|
| Tier | Environments | Apps | Retention | Features |
|
||||||
|
|------|-------------|------|-----------|----------|
|
||||||
|
| LOW | 1 | 3 | 7 days | Topology |
|
||||||
|
| MID | 2 | 10 | 30 days | + Lineage, Correlation |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests only (no Docker required)
|
||||||
|
mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java"
|
||||||
|
|
||||||
|
# Integration tests (requires Docker Desktop)
|
||||||
|
mvn test -B -Dtest="EnvironmentControllerTest,AppControllerTest,DeploymentControllerTest"
|
||||||
|
|
||||||
|
# All tests
|
||||||
|
mvn verify -B
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build JAR
|
||||||
|
mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t cameleer-saas:local .
|
||||||
|
|
||||||
|
# Use local image
|
||||||
|
VERSION=local docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Logto fails to start**: Check that PostgreSQL is healthy first. Logto needs the `logto` database created by `docker/init-databases.sh`. Run `docker compose logs logto` for details.
|
||||||
|
|
||||||
|
**cameleer-saas won't start**: Check `docker compose logs cameleer-saas`. Common issues:
|
||||||
|
- PostgreSQL not ready (wait for healthcheck)
|
||||||
|
- Flyway migration conflict (check for manual schema changes)
|
||||||
|
|
||||||
|
**Ephemeral key warnings**: `No Ed25519 key files configured -- generating ephemeral keys (dev mode)` is normal in development. For production, generate keys as described above.
|
||||||
|
|
||||||
|
**Container deployment fails**: Check that Docker socket is mounted (`/var/run/docker.sock`) and the `cameleer-runtime-base` image is available. Pull it with: `docker pull gitea.siegeln.net/cameleer/cameleer-runtime-base:latest`
|
||||||
@@ -79,6 +79,9 @@ services:
|
|||||||
- traefik.http.services.api.loadbalancer.server.port=8080
|
- traefik.http.services.api.loadbalancer.server.port=8080
|
||||||
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
||||||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
- 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:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
@@ -94,6 +97,7 @@ services:
|
|||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
||||||
@@ -101,6 +105,10 @@ services:
|
|||||||
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
|
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
|
||||||
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
|
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
|
||||||
- traefik.http.services.observe.loadbalancer.server.port=8080
|
- traefik.http.services.observe.loadbalancer.server.port=8080
|
||||||
|
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||||||
|
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
||||||
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,789 @@
|
|||||||
|
# Phase 4: Observability Pipeline + Inbound Routing — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer3-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
||||||
|
|
||||||
|
**Architecture:** Wiring phase — cameleer3-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer3-server for agent registration
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java` — Request DTO for PATCH routing
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java` — Startup connectivity verification
|
||||||
|
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java` — Unit tests
|
||||||
|
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusControllerTest.java` — Integration tests
|
||||||
|
- `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql` — Migration
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java` — Add `labels` field
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java` — Apply labels on container create
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java` — Add `domain` property
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java` — Add `exposedPort` field
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppService.java` — Add `updateRouting` method
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppController.java` — Add PATCH routing endpoint
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java` — Add `exposedPort` + `routeUrl` fields
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java` — Build labels for Traefik routing
|
||||||
|
- `src/main/resources/application.yml` — Add `domain` property
|
||||||
|
- `docker-compose.yml` — Add dashboard Traefik route, `CAMELEER_TENANT_ID`
|
||||||
|
- `.env.example` — Add `CAMELEER_TENANT_SLUG`
|
||||||
|
- `HOWTO.md` — Update with observability + routing docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Database Migration + Entity Changes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create migration V010**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add exposedPort field to AppEntity**
|
||||||
|
|
||||||
|
Add after `previousDeploymentId` field:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Column(name = "exposed_port")
|
||||||
|
private Integer exposedPort;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getter and setter:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public Integer getExposedPort() { return exposedPort; }
|
||||||
|
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java
|
||||||
|
git commit -m "feat: add exposed_port column to apps table"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: StartContainerRequest Labels + DockerRuntimeOrchestrator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add labels field to StartContainerRequest**
|
||||||
|
|
||||||
|
Replace the current record with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.runtime;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record StartContainerRequest(
|
||||||
|
String imageRef,
|
||||||
|
String containerName,
|
||||||
|
String network,
|
||||||
|
Map<String, String> envVars,
|
||||||
|
long memoryLimitBytes,
|
||||||
|
int cpuShares,
|
||||||
|
int healthCheckPort,
|
||||||
|
Map<String, String> labels
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply labels in DockerRuntimeOrchestrator.startContainer**
|
||||||
|
|
||||||
|
In the `startContainer` method, after `.withHostConfig(hostConfig)` and before `.withHealthcheck(...)`, add:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix all existing callers of StartContainerRequest**
|
||||||
|
|
||||||
|
The `DeploymentService.executeDeploymentAsync` method creates a `StartContainerRequest`. Add `Map.of()` as the labels argument (empty labels for now — routing labels come in Task 5):
|
||||||
|
|
||||||
|
Find the existing `new StartContainerRequest(...)` call and add `Map.of()` as the last argument.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify compilation and run unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
|
||||||
|
git commit -m "feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: RuntimeConfig Domain + AppResponse + AppService Routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppController.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java`
|
||||||
|
- Modify: `src/main/resources/application.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add domain property to RuntimeConfig**
|
||||||
|
|
||||||
|
Add field and getter:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Value("${cameleer.runtime.domain:localhost}")
|
||||||
|
private String domain;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add domain to application.yml**
|
||||||
|
|
||||||
|
In the `cameleer.runtime` section, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update AppResponse to include exposedPort and routeUrl**
|
||||||
|
|
||||||
|
Replace the record:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.app.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record AppResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID environmentId,
|
||||||
|
String slug,
|
||||||
|
String displayName,
|
||||||
|
String jarOriginalFilename,
|
||||||
|
Long jarSizeBytes,
|
||||||
|
String jarChecksum,
|
||||||
|
Integer exposedPort,
|
||||||
|
String routeUrl,
|
||||||
|
UUID currentDeploymentId,
|
||||||
|
UUID previousDeploymentId,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create UpdateRoutingRequest**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
public record UpdateRoutingRequest(
|
||||||
|
Integer exposedPort
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add updateRouting method to AppService**
|
||||||
|
|
||||||
|
```java
|
||||||
|
public AppEntity updateRouting(UUID appId, Integer exposedPort, UUID actorId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
app.setExposedPort(exposedPort);
|
||||||
|
return appRepository.save(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update AppController — add PATCH routing endpoint and update toResponse**
|
||||||
|
|
||||||
|
Add the endpoint:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@PatchMapping("/{appId}/routing")
|
||||||
|
public ResponseEntity<AppResponse> updateRouting(
|
||||||
|
@PathVariable UUID environmentId,
|
||||||
|
@PathVariable UUID appId,
|
||||||
|
@RequestBody UpdateRoutingRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
var actorId = resolveActorId(authentication);
|
||||||
|
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
||||||
|
var env = environmentService.getById(app.getEnvironmentId()).orElse(null);
|
||||||
|
return ResponseEntity.ok(toResponse(app, env));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires adding `EnvironmentService` and `RuntimeConfig` as constructor dependencies to `AppController`. Update the constructor.
|
||||||
|
|
||||||
|
Update `toResponse` to accept the environment and compute the route URL:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private AppResponse toResponse(AppEntity app, EnvironmentEntity env) {
|
||||||
|
String routeUrl = null;
|
||||||
|
if (app.getExposedPort() != null && env != null) {
|
||||||
|
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
|
||||||
|
if (tenant != null) {
|
||||||
|
routeUrl = "http://" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenant.getSlug() + "." + runtimeConfig.getDomain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AppResponse(
|
||||||
|
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||||
|
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||||
|
app.getExposedPort(), routeUrl,
|
||||||
|
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||||
|
app.getCreatedAt(), app.getUpdatedAt());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires adding `TenantRepository` as a constructor dependency too. Update the existing `toResponse(AppEntity)` calls in other methods to pass the environment — look up the environment from the `environmentId` path variable or from `environmentService`.
|
||||||
|
|
||||||
|
For the list/get/create endpoints that already have `environmentId` in the path, look up the environment once and pass it.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppService.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppController.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java \
|
||||||
|
src/main/resources/application.yml
|
||||||
|
git commit -m "feat: add exposed port routing and route URL to app API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Agent Status + Observability Status Endpoints (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java`
|
||||||
|
- Create: `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create DTOs**
|
||||||
|
|
||||||
|
`AgentStatusResponse.java`:
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record AgentStatusResponse(
|
||||||
|
boolean registered,
|
||||||
|
String state,
|
||||||
|
Instant lastHeartbeat,
|
||||||
|
List<String> routeIds,
|
||||||
|
String applicationId,
|
||||||
|
String environmentId
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ObservabilityStatusResponse.java`:
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ObservabilityStatusResponse(
|
||||||
|
boolean hasTraces,
|
||||||
|
boolean hasMetrics,
|
||||||
|
boolean hasDiagrams,
|
||||||
|
Instant lastTraceAt,
|
||||||
|
long traceCount24h
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing tests**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AgentStatusServiceTest {
|
||||||
|
|
||||||
|
@Mock private AppRepository appRepository;
|
||||||
|
@Mock private EnvironmentRepository environmentRepository;
|
||||||
|
@Mock private RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
private AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
|
||||||
|
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_appNotFound_shouldThrow() {
|
||||||
|
when(appRepository.findById(any())).thenReturn(Optional.empty());
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> agentStatusService.getAgentStatus(UUID.randomUUID()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_shouldReturnUnknownWhenServerUnreachable() {
|
||||||
|
var appId = UUID.randomUUID();
|
||||||
|
var envId = UUID.randomUUID();
|
||||||
|
|
||||||
|
var app = new AppEntity();
|
||||||
|
app.setId(appId);
|
||||||
|
app.setEnvironmentId(envId);
|
||||||
|
app.setSlug("my-app");
|
||||||
|
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||||
|
|
||||||
|
var env = new EnvironmentEntity();
|
||||||
|
env.setId(envId);
|
||||||
|
env.setSlug("default");
|
||||||
|
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||||
|
|
||||||
|
var result = agentStatusService.getAgentStatus(appId);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.registered());
|
||||||
|
assertEquals("UNKNOWN", result.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement AgentStatusService**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AgentStatusService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentStatusService.class);
|
||||||
|
|
||||||
|
private final AppRepository appRepository;
|
||||||
|
private final EnvironmentRepository environmentRepository;
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
@Qualifier("clickHouseDataSource")
|
||||||
|
private DataSource clickHouseDataSource;
|
||||||
|
|
||||||
|
public AgentStatusService(AppRepository appRepository,
|
||||||
|
EnvironmentRepository environmentRepository,
|
||||||
|
RuntimeConfig runtimeConfig) {
|
||||||
|
this.appRepository = appRepository;
|
||||||
|
this.environmentRepository = environmentRepository;
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
this.restClient = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentStatusResponse getAgentStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Environment not found"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = restClient.get()
|
||||||
|
.uri("/api/v1/agents")
|
||||||
|
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
for (var agentObj : response) {
|
||||||
|
if (agentObj instanceof java.util.Map<?, ?> agent) {
|
||||||
|
var agentAppId = String.valueOf(agent.get("applicationId"));
|
||||||
|
var agentEnvId = String.valueOf(agent.get("environmentId"));
|
||||||
|
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
|
||||||
|
var state = String.valueOf(agent.getOrDefault("state", "UNKNOWN"));
|
||||||
|
var routeIds = agent.get("routeIds");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var routes = routeIds instanceof List<?> r ? (List<String>) r : List.<String>of();
|
||||||
|
return new AgentStatusResponse(true, state, null, routes,
|
||||||
|
agentAppId, agentEnvId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
|
||||||
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query agent status from cameleer3-server: {}", e.getMessage());
|
||||||
|
return new AgentStatusResponse(false, "UNKNOWN", null,
|
||||||
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Environment not found"));
|
||||||
|
|
||||||
|
if (clickHouseDataSource == null) {
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var conn = clickHouseDataSource.getConnection();
|
||||||
|
var ps = conn.prepareStatement("""
|
||||||
|
SELECT
|
||||||
|
count() as trace_count,
|
||||||
|
max(start_time) as last_trace
|
||||||
|
FROM executions
|
||||||
|
WHERE application_id = ? AND environment = ?
|
||||||
|
AND start_time > now() - INTERVAL 24 HOUR
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, app.getSlug());
|
||||||
|
ps.setString(2, env.getSlug());
|
||||||
|
|
||||||
|
try (var rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
var count = rs.getLong("trace_count");
|
||||||
|
var lastTrace = rs.getTimestamp("last_trace");
|
||||||
|
return new ObservabilityStatusResponse(
|
||||||
|
count > 0, false, false,
|
||||||
|
lastTrace != null ? lastTrace.toInstant() : null,
|
||||||
|
count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query observability status from ClickHouse: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create AgentStatusController**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/apps/{appId}")
|
||||||
|
public class AgentStatusController {
|
||||||
|
|
||||||
|
private final AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
public AgentStatusController(AgentStatusService agentStatusService) {
|
||||||
|
this.agentStatusService = agentStatusService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/agent-status")
|
||||||
|
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
var status = agentStatusService.getAgentStatus(appId);
|
||||||
|
return ResponseEntity.ok(status);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/observability-status")
|
||||||
|
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
var status = agentStatusService.getObservabilityStatus(appId);
|
||||||
|
return ResponseEntity.ok(status);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn test -pl . -Dtest=AgentStatusServiceTest -B`
|
||||||
|
Expected: 2 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/observability/ \
|
||||||
|
src/test/java/net/siegeln/cameleer/saas/observability/
|
||||||
|
git commit -m "feat: add agent status and observability status endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Traefik Routing Labels in DeploymentService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build Traefik labels when app has exposedPort**
|
||||||
|
|
||||||
|
In `executeDeploymentAsync`, after building the `envVars` map and before creating `startRequest`, add label computation:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Build Traefik labels for inbound routing
|
||||||
|
var labels = new java.util.HashMap<String, String>();
|
||||||
|
if (app.getExposedPort() != null) {
|
||||||
|
labels.put("traefik.enable", "true");
|
||||||
|
labels.put("traefik.http.routers." + containerName + ".rule",
|
||||||
|
"Host(`" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenant.getSlug() + "." + runtimeConfig.getDomain() + "`)");
|
||||||
|
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
|
||||||
|
String.valueOf(app.getExposedPort()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pass `labels` to the `StartContainerRequest` constructor (replacing the `Map.of()` added in Task 2).
|
||||||
|
|
||||||
|
Note: The `tenant` variable is already looked up earlier in the method for container naming.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -pl . -Dtest=DeploymentServiceTest -B`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
|
||||||
|
git commit -m "feat: add Traefik routing labels for customer apps with exposed ports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Connectivity Health Check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create startup connectivity check**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ConnectivityHealthCheck {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConnectivityHealthCheck.class);
|
||||||
|
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
public ConnectivityHealthCheck(RuntimeConfig runtimeConfig) {
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void verifyConnectivity() {
|
||||||
|
checkCameleer3Server();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkCameleer3Server() {
|
||||||
|
try {
|
||||||
|
var client = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
var response = client.get()
|
||||||
|
.uri("/actuator/health")
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
log.info("cameleer3-server connectivity: OK ({})",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
} else {
|
||||||
|
log.warn("cameleer3-server connectivity: HTTP {} ({})",
|
||||||
|
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
|
||||||
|
git commit -m "feat: add cameleer3-server startup connectivity check"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Docker Compose + .env + CI Updates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Modify: `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
|
||||||
|
|
||||||
|
In the `cameleer3-server` service:
|
||||||
|
|
||||||
|
Add to environment section:
|
||||||
|
```yaml
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new Traefik labels (after existing ones):
|
||||||
|
```yaml
|
||||||
|
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||||||
|
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
||||||
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update .env.example**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```
|
||||||
|
CAMELEER_TENANT_SLUG=default
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update CI excludes**
|
||||||
|
|
||||||
|
In `.gitea/workflows/ci.yml`, add `**/AgentStatusControllerTest.java` to the Surefire excludes (if integration test exists).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml .env.example .gitea/workflows/ci.yml
|
||||||
|
git commit -m "feat: add dashboard Traefik route and CAMELEER_TENANT_ID config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update HOWTO.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `HOWTO.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add observability and routing sections**
|
||||||
|
|
||||||
|
After the "Deploy a Camel Application" section, add:
|
||||||
|
|
||||||
|
**Observability Dashboard section** — explains how to access the dashboard at `/dashboard`, what data is visible.
|
||||||
|
|
||||||
|
**Inbound HTTP Routing section** — explains how to set `exposedPort` on an app and what URL to use.
|
||||||
|
|
||||||
|
**Agent Status section** — explains the agent-status and observability-status endpoints.
|
||||||
|
|
||||||
|
Update the API Reference table with the new endpoints:
|
||||||
|
- `GET /api/apps/{aid}/agent-status`
|
||||||
|
- `GET /api/apps/{aid}/observability-status`
|
||||||
|
- `PATCH /api/environments/{eid}/apps/{aid}/routing`
|
||||||
|
|
||||||
|
Update the .env table to include `CAMELEER_TENANT_SLUG`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add HOWTO.md
|
||||||
|
git commit -m "docs: update HOWTO with observability dashboard, routing, and agent status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Spec Coverage
|
||||||
|
|
||||||
|
| Spec Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Serve cameleer3-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
||||||
|
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
|
||||||
|
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
|
||||||
|
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
|
||||||
|
| Inbound HTTP routing (exposedPort + Traefik labels) | Tasks 1, 2, 3, 5 |
|
||||||
|
| StartContainerRequest labels support | Task 2 |
|
||||||
|
| AppResponse with routeUrl | Task 3 |
|
||||||
|
| PATCH routing API | Task 3 |
|
||||||
|
| Startup connectivity check | Task 6 |
|
||||||
|
| Docker Compose changes | Task 7 |
|
||||||
|
| .env.example updates | Task 7 |
|
||||||
|
| HOWTO.md updates | Task 8 |
|
||||||
|
| V010 migration | Task 1 |
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Phase 4: Observability Pipeline + Inbound Routing
|
||||||
|
|
||||||
|
**Date:** 2026-04-04
|
||||||
|
**Status:** Draft
|
||||||
|
**Depends on:** Phase 3 (Runtime Orchestration + Environments)
|
||||||
|
**Gitea issue:** #28
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 3 delivered the managed Camel runtime: customers upload a JAR, the platform builds a container with the cameleer3 agent injected, and deploys it. The agent connects to cameleer3-server and sends traces, metrics, diagrams, and logs to ClickHouse. But there is no way for the user to see this data yet, and customer apps that expose HTTP endpoints are not reachable.
|
||||||
|
|
||||||
|
Phase 4 completes the loop: deploy an app, hit its endpoint, see the traces in the dashboard.
|
||||||
|
|
||||||
|
cameleer3-server already has the complete observability stack — ClickHouse schemas with `tenant_id` partitioning, full search/stats/diagram/log REST APIs, and a React SPA dashboard. Phase 4 is a **wiring phase**, not a build-from-scratch phase.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Observability UI | Serve existing cameleer3-server React SPA via Traefik | Already built. SaaS management UI is Phase 9 — observability UI is not SaaS-specific. |
|
||||||
|
| API access | Traefik routes directly to cameleer3-server with forward-auth | No proxy layer needed. Forward-auth validates user, injects headers. Server API works as-is. |
|
||||||
|
| Server changes | None | Single-tenant Docker mode works out of the box. `CAMELEER_TENANT_ID` env var already supported. |
|
||||||
|
| Agent changes | None | Agent already sends `applicationId`, `environmentId`, connects to `CAMELEER_EXPORT_ENDPOINT`. |
|
||||||
|
| Tenant ID | Set `CAMELEER_TENANT_ID` to tenant slug in Docker Compose | Tags ClickHouse data with the real tenant identity from day one. Avoids `'default'` → real-id migration later. |
|
||||||
|
| Inbound routing | Optional `exposedPort` on deployment, Traefik labels on customer containers | Thin feature. `{app}.{env}.{tenant}.{domain}` routing via Traefik Host rule. |
|
||||||
|
|
||||||
|
## What's Already Working (Phase 3)
|
||||||
|
|
||||||
|
- Customer containers on the `cameleer` bridge network
|
||||||
|
- Agent configured: `CAMELEER_AUTH_TOKEN`, `CAMELEER_EXPORT_ENDPOINT=http://cameleer3-server:8081`, `CAMELEER_APPLICATION_ID`, `CAMELEER_ENVIRONMENT_ID`
|
||||||
|
- cameleer3-server writes traces/metrics/diagrams/logs to ClickHouse
|
||||||
|
- Traefik routes `/observe/*` to cameleer3-server with forward-auth middleware
|
||||||
|
- Forward-auth endpoint at `/auth/verify` validates JWT, returns `X-Tenant-Id`, `X-User-Id`, `X-User-Email` headers
|
||||||
|
|
||||||
|
## Component 1: Serve cameleer3-server Dashboard
|
||||||
|
|
||||||
|
### Traefik Routing
|
||||||
|
|
||||||
|
Add Traefik labels to the cameleer3-server service in `docker-compose.yml` to serve the React SPA:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Existing (Phase 3):
|
||||||
|
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
||||||
|
- traefik.http.routers.observe.middlewares=forward-auth
|
||||||
|
|
||||||
|
# New (Phase 4):
|
||||||
|
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth
|
||||||
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The cameleer3-server SPA is served from its own embedded web server. The SPA already calls the server's API endpoints at relative paths — the existing `/observe/*` Traefik route handles those requests with forward-auth.
|
||||||
|
|
||||||
|
**Note:** If the cameleer3-server SPA expects to be served from `/` rather than `/dashboard`, a Traefik StripPrefix middleware may be needed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||||||
|
```
|
||||||
|
|
||||||
|
This depends on how the cameleer3-server SPA is configured (base path). To be verified during implementation.
|
||||||
|
|
||||||
|
### CAMELEER_TENANT_ID Configuration
|
||||||
|
|
||||||
|
Set `CAMELEER_TENANT_ID` on the cameleer3-server service so all ingested data is tagged with the real tenant slug:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameleer3-server:
|
||||||
|
environment:
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Docker single-tenant stack, this is set once during initial setup (e.g., `CAMELEER_TENANT_SLUG=acme` in `.env`). All ClickHouse data is then partitioned under this tenant ID.
|
||||||
|
|
||||||
|
Add `CAMELEER_TENANT_SLUG` to `.env.example`.
|
||||||
|
|
||||||
|
## Component 2: Agent Connectivity Verification
|
||||||
|
|
||||||
|
New endpoint in cameleer-saas to check whether a deployed app's agent has successfully registered with cameleer3-server and is sending data.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/apps/{appId}/agent-status
|
||||||
|
Returns: 200 + AgentStatusResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentStatusResponse
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record AgentStatusResponse(
|
||||||
|
boolean registered,
|
||||||
|
String state, // ACTIVE, STALE, DEAD, UNKNOWN
|
||||||
|
Instant lastHeartbeat,
|
||||||
|
List<String> routeIds,
|
||||||
|
String applicationId,
|
||||||
|
String environmentId
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
`AgentStatusService` in cameleer-saas calls cameleer3-server's agent registry API:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET http://cameleer3-server:8081/api/v1/agents
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns the list of registered agents. The service filters by `applicationId` matching the app's slug and `environmentId` matching the environment's slug.
|
||||||
|
|
||||||
|
If the cameleer3-server doesn't expose a public agent listing endpoint, the alternative is to query ClickHouse directly for recent data:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT max(timestamp) as last_seen
|
||||||
|
FROM container_logs
|
||||||
|
WHERE app_id = ? AND deployment_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The preferred approach is the agent registry API. If it requires authentication, cameleer-saas can use the shared `CAMELEER_AUTH_TOKEN` as a machine token.
|
||||||
|
|
||||||
|
### Integration with Deployment Status
|
||||||
|
|
||||||
|
After a deployment reaches `RUNNING` status (container healthy), the platform can poll agent-status to confirm the agent has registered. This could be surfaced as a sub-status:
|
||||||
|
|
||||||
|
- `RUNNING` — container is healthy
|
||||||
|
- `RUNNING_CONNECTED` — container healthy + agent registered with server
|
||||||
|
- `RUNNING_DISCONNECTED` — container healthy but agent not yet registered (timeout: 30s)
|
||||||
|
|
||||||
|
This is a nice-to-have enhancement on top of the basic agent-status endpoint.
|
||||||
|
|
||||||
|
## Component 3: Inbound HTTP Routing for Customer Apps
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Add `exposed_port` column to the `apps` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the port the customer's Camel app listens on inside the container (e.g., 8080 for a Spring Boot REST app). When set, Traefik routes external traffic to this port.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/apps/{appId}/routing
|
||||||
|
Body: { "exposedPort": 8080 } // or null to disable routing
|
||||||
|
Returns: 200 + AppResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
The routable URL is computed and included in `AppResponse`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// In AppResponse, add:
|
||||||
|
String routeUrl // e.g., "http://order-svc.default.acme.localhost" or null if no routing
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
{app-slug}.{env-slug}.{tenant-slug}.{domain}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `order-svc.default.acme.localhost`
|
||||||
|
|
||||||
|
The `{domain}` comes from the `DOMAIN` env var (already in `.env.example`).
|
||||||
|
|
||||||
|
### DockerRuntimeOrchestrator Changes
|
||||||
|
|
||||||
|
When starting a container for an app that has `exposedPort` set, add Traefik labels:
|
||||||
|
|
||||||
|
```java
|
||||||
|
var labels = new HashMap<String, String>();
|
||||||
|
labels.put("traefik.enable", "true");
|
||||||
|
labels.put("traefik.http.routers." + containerName + ".rule",
|
||||||
|
"Host(`" + app.getSlug() + "." + env.getSlug() + "." + tenant.getSlug() + "." + domain + "`)");
|
||||||
|
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
|
||||||
|
String.valueOf(app.getExposedPort()));
|
||||||
|
```
|
||||||
|
|
||||||
|
These labels are set on the Docker container via docker-java's `withLabels()` on the `CreateContainerCmd`.
|
||||||
|
|
||||||
|
Traefik auto-discovers labeled containers on the `cameleer` network (already configured in `traefik.yml` with `exposedByDefault: false`).
|
||||||
|
|
||||||
|
### StartContainerRequest Changes
|
||||||
|
|
||||||
|
Add optional fields to `StartContainerRequest`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record StartContainerRequest(
|
||||||
|
String imageRef,
|
||||||
|
String containerName,
|
||||||
|
String network,
|
||||||
|
Map<String, String> envVars,
|
||||||
|
long memoryLimitBytes,
|
||||||
|
int cpuShares,
|
||||||
|
int healthCheckPort,
|
||||||
|
Map<String, String> labels // NEW: Traefik routing labels
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RuntimeConfig Addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameleer:
|
||||||
|
runtime:
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component 4: End-to-End Connectivity Health
|
||||||
|
|
||||||
|
### Startup Verification
|
||||||
|
|
||||||
|
On application startup, cameleer-saas verifies that cameleer3-server is reachable:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void verifyConnectivity() {
|
||||||
|
// HTTP GET http://cameleer3-server:8081/actuator/health
|
||||||
|
// Log result: "cameleer3-server connectivity: OK" or "FAILED: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a best-effort check, not a hard dependency. If cameleer3-server is not yet running (e.g., starting up), the SaaS platform still starts. The check is logged for diagnostics.
|
||||||
|
|
||||||
|
### ClickHouse Data Verification
|
||||||
|
|
||||||
|
Add a lightweight endpoint for checking whether a deployed app is producing observability data:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/apps/{appId}/observability-status
|
||||||
|
Returns: 200 + ObservabilityStatusResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record ObservabilityStatusResponse(
|
||||||
|
boolean hasTraces,
|
||||||
|
boolean hasMetrics,
|
||||||
|
boolean hasDiagrams,
|
||||||
|
Instant lastTraceAt,
|
||||||
|
long traceCount24h
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation queries ClickHouse:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
count() > 0 as has_traces,
|
||||||
|
max(start_time) as last_trace,
|
||||||
|
count() as trace_count_24h
|
||||||
|
FROM executions
|
||||||
|
WHERE tenant_id = ? AND application_id = ? AND environment = ?
|
||||||
|
AND start_time > now() - INTERVAL 24 HOUR
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires cameleer-saas to query ClickHouse directly (the `clickHouseDataSource` bean from Phase 3). The query is scoped by tenant + application + environment.
|
||||||
|
|
||||||
|
## Docker Compose Changes
|
||||||
|
|
||||||
|
### cameleer3-server labels (add dashboard route)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameleer3-server:
|
||||||
|
environment:
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
|
labels:
|
||||||
|
# Existing:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
||||||
|
- traefik.http.routers.observe.middlewares=forward-auth
|
||||||
|
- traefik.http.services.observe.loadbalancer.server.port=8080
|
||||||
|
# New:
|
||||||
|
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||||||
|
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
||||||
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### .env.example addition
|
||||||
|
|
||||||
|
```
|
||||||
|
CAMELEER_TENANT_SLUG=default
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V010__add_exposed_port_to_apps.sql
|
||||||
|
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Configuration Properties
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameleer:
|
||||||
|
runtime:
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
1. Deploy a sample Camel REST app with `exposedPort: 8080`
|
||||||
|
2. `curl http://order-svc.default.acme.localhost` hits the Camel app
|
||||||
|
3. The Camel route processes the request
|
||||||
|
4. cameleer3 agent captures the trace and sends to cameleer3-server
|
||||||
|
5. `GET /api/apps/{appId}/agent-status` shows `registered: true, state: ACTIVE`
|
||||||
|
6. `GET /api/apps/{appId}/observability-status` shows `hasTraces: true`
|
||||||
|
7. Open `http://localhost/dashboard` — cameleer3-server SPA loads
|
||||||
|
8. Traces visible in the dashboard for the deployed app
|
||||||
|
9. Route topology graph shows the Camel route structure
|
||||||
|
10. `CAMELEER_TENANT_ID` is set to the tenant slug in ClickHouse data
|
||||||
|
|
||||||
|
## What Phase 4 Does NOT Touch
|
||||||
|
|
||||||
|
- No changes to cameleer3-server code (works as-is for single-tenant Docker mode)
|
||||||
|
- No changes to the cameleer3 agent
|
||||||
|
- No new ClickHouse schemas (cameleer3-server manages its own)
|
||||||
|
- No SaaS management UI (Phase 9)
|
||||||
|
- No K8s-specific changes (Phase 5)
|
||||||
@@ -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
|
||||||
@@ -3,14 +3,19 @@ package net.siegeln.cameleer.saas.app;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
||||||
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentService;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -26,10 +31,19 @@ public class AppController {
|
|||||||
|
|
||||||
private final AppService appService;
|
private final AppService appService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final EnvironmentService environmentService;
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
|
||||||
public AppController(AppService appService, ObjectMapper objectMapper) {
|
public AppController(AppService appService, ObjectMapper objectMapper,
|
||||||
|
EnvironmentService environmentService,
|
||||||
|
RuntimeConfig runtimeConfig,
|
||||||
|
TenantRepository tenantRepository) {
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.environmentService = environmentService;
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = "multipart/form-data")
|
@PostMapping(consumes = "multipart/form-data")
|
||||||
@@ -103,6 +117,21 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{appId}/routing")
|
||||||
|
public ResponseEntity<AppResponse> updateRouting(
|
||||||
|
@PathVariable UUID environmentId,
|
||||||
|
@PathVariable UUID appId,
|
||||||
|
@RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
var actorId = resolveActorId(authentication);
|
||||||
|
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
||||||
|
return ResponseEntity.ok(toResponse(app));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private UUID resolveActorId(Authentication authentication) {
|
private UUID resolveActorId(Authentication authentication) {
|
||||||
String sub = authentication.getName();
|
String sub = authentication.getName();
|
||||||
try {
|
try {
|
||||||
@@ -112,19 +141,23 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppResponse toResponse(AppEntity entity) {
|
private AppResponse toResponse(AppEntity app) {
|
||||||
|
String routeUrl = null;
|
||||||
|
if (app.getExposedPort() != null) {
|
||||||
|
var env = environmentService.getById(app.getEnvironmentId()).orElse(null);
|
||||||
|
if (env != null) {
|
||||||
|
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
|
||||||
|
if (tenant != null) {
|
||||||
|
routeUrl = "http://" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenant.getSlug() + "." + runtimeConfig.getDomain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return new AppResponse(
|
return new AppResponse(
|
||||||
entity.getId(),
|
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||||
entity.getEnvironmentId(),
|
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||||
entity.getSlug(),
|
app.getExposedPort(), routeUrl,
|
||||||
entity.getDisplayName(),
|
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||||
entity.getJarOriginalFilename(),
|
app.getCreatedAt(), app.getUpdatedAt());
|
||||||
entity.getJarSizeBytes(),
|
|
||||||
entity.getJarChecksum(),
|
|
||||||
entity.getCurrentDeploymentId(),
|
|
||||||
entity.getPreviousDeploymentId(),
|
|
||||||
entity.getCreatedAt(),
|
|
||||||
entity.getUpdatedAt()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class AppEntity {
|
|||||||
@Column(name = "previous_deployment_id")
|
@Column(name = "previous_deployment_id")
|
||||||
private UUID previousDeploymentId;
|
private UUID previousDeploymentId;
|
||||||
|
|
||||||
|
@Column(name = "exposed_port")
|
||||||
|
private Integer exposedPort;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ public class AppEntity {
|
|||||||
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
||||||
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
||||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
||||||
|
public Integer getExposedPort() { return exposedPort; }
|
||||||
|
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
|
||||||
public Instant getCreatedAt() { return createdAt; }
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
public Instant getUpdatedAt() { return updatedAt; }
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ public class AppService {
|
|||||||
null, null, "SUCCESS", null);
|
null, null, "SUCCESS", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppEntity updateRouting(UUID appId, Integer exposedPort, UUID actorId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
app.setExposedPort(exposedPort);
|
||||||
|
return appRepository.save(app);
|
||||||
|
}
|
||||||
|
|
||||||
public Path resolveJarPath(String relativePath) {
|
public Path resolveJarPath(String relativePath) {
|
||||||
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import java.time.Instant;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record AppResponse(
|
public record AppResponse(
|
||||||
UUID id, UUID environmentId, String slug, String displayName,
|
UUID id,
|
||||||
String jarOriginalFilename, Long jarSizeBytes, String jarChecksum,
|
UUID environmentId,
|
||||||
UUID currentDeploymentId, UUID previousDeploymentId,
|
String slug,
|
||||||
Instant createdAt, Instant updatedAt
|
String displayName,
|
||||||
|
String jarOriginalFilename,
|
||||||
|
Long jarSizeBytes,
|
||||||
|
String jarChecksum,
|
||||||
|
Integer exposedPort,
|
||||||
|
String routeUrl,
|
||||||
|
UUID currentDeploymentId,
|
||||||
|
UUID previousDeploymentId,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,17 @@ public class DeploymentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build Traefik labels for inbound routing
|
||||||
|
var labels = new java.util.HashMap<String, String>();
|
||||||
|
if (app.getExposedPort() != null) {
|
||||||
|
labels.put("traefik.enable", "true");
|
||||||
|
labels.put("traefik.http.routers." + containerName + ".rule",
|
||||||
|
"Host(`" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenantSlug + "." + runtimeConfig.getDomain() + "`)");
|
||||||
|
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
|
||||||
|
String.valueOf(app.getExposedPort()));
|
||||||
|
}
|
||||||
|
|
||||||
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||||
deployment.getImageRef(),
|
deployment.getImageRef(),
|
||||||
containerName,
|
containerName,
|
||||||
@@ -140,7 +151,8 @@ public class DeploymentService {
|
|||||||
),
|
),
|
||||||
runtimeConfig.parseMemoryLimitBytes(),
|
runtimeConfig.parseMemoryLimitBytes(),
|
||||||
runtimeConfig.getContainerCpuShares(),
|
runtimeConfig.getContainerCpuShares(),
|
||||||
runtimeConfig.getAgentHealthPort()
|
runtimeConfig.getAgentHealthPort(),
|
||||||
|
labels
|
||||||
));
|
));
|
||||||
|
|
||||||
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
|
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/apps/{appId}")
|
||||||
|
public class AgentStatusController {
|
||||||
|
|
||||||
|
private final AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
public AgentStatusController(AgentStatusService agentStatusService) {
|
||||||
|
this.agentStatusService = agentStatusService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/agent-status")
|
||||||
|
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/observability-status")
|
||||||
|
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AgentStatusService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentStatusService.class);
|
||||||
|
|
||||||
|
private final AppRepository appRepository;
|
||||||
|
private final EnvironmentRepository environmentRepository;
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
@Qualifier("clickHouseDataSource")
|
||||||
|
private DataSource clickHouseDataSource;
|
||||||
|
|
||||||
|
public AgentStatusService(AppRepository appRepository,
|
||||||
|
EnvironmentRepository environmentRepository,
|
||||||
|
RuntimeConfig runtimeConfig) {
|
||||||
|
this.appRepository = appRepository;
|
||||||
|
this.environmentRepository = environmentRepository;
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
this.restClient = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentStatusResponse getAgentStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||||
|
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> agents = restClient.get()
|
||||||
|
.uri("/api/v1/agents")
|
||||||
|
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
|
||||||
|
if (agents == null) {
|
||||||
|
return unknownStatus(app.getSlug(), env.getSlug());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map<String, Object> agent : agents) {
|
||||||
|
String agentAppId = (String) agent.get("applicationId");
|
||||||
|
String agentEnvId = (String) agent.get("environmentId");
|
||||||
|
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
|
||||||
|
String state = (String) agent.getOrDefault("state", "UNKNOWN");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> routeIds = (List<String>) agent.getOrDefault("routeIds", Collections.emptyList());
|
||||||
|
return new AgentStatusResponse(
|
||||||
|
true,
|
||||||
|
state,
|
||||||
|
null,
|
||||||
|
routeIds,
|
||||||
|
app.getSlug(),
|
||||||
|
env.getSlug()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unknownStatus(app.getSlug(), env.getSlug());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to fetch agent status from cameleer3-server: {}", e.getMessage());
|
||||||
|
return unknownStatus(app.getSlug(), env.getSlug());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||||
|
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||||
|
|
||||||
|
if (clickHouseDataSource == null) {
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var conn = clickHouseDataSource.getConnection()) {
|
||||||
|
String sql = "SELECT count() as cnt, max(start_time) as last_trace " +
|
||||||
|
"FROM executions " +
|
||||||
|
"WHERE application_id = ? AND environment_id = ? " +
|
||||||
|
"AND start_time >= now() - INTERVAL 24 HOUR";
|
||||||
|
try (var stmt = conn.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, app.getSlug());
|
||||||
|
stmt.setString(2, env.getSlug());
|
||||||
|
try (var rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
long count = rs.getLong("cnt");
|
||||||
|
Timestamp lastTrace = rs.getTimestamp("last_trace");
|
||||||
|
boolean hasTraces = count > 0;
|
||||||
|
return new ObservabilityStatusResponse(
|
||||||
|
hasTraces,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
hasTraces && lastTrace != null ? lastTrace.toInstant() : null,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query ClickHouse for observability status: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentStatusResponse unknownStatus(String applicationId, String environmentId) {
|
||||||
|
return new AgentStatusResponse(false, "UNKNOWN", null, Collections.emptyList(), applicationId, environmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ConnectivityHealthCheck {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConnectivityHealthCheck.class);
|
||||||
|
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
public ConnectivityHealthCheck(RuntimeConfig runtimeConfig) {
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void verifyConnectivity() {
|
||||||
|
checkCameleer3Server();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkCameleer3Server() {
|
||||||
|
try {
|
||||||
|
var client = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
var response = client.get()
|
||||||
|
.uri("/actuator/health")
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
log.info("cameleer3-server connectivity: OK ({})",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
} else {
|
||||||
|
log.warn("cameleer3-server connectivity: HTTP {} ({})",
|
||||||
|
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record AgentStatusResponse(
|
||||||
|
boolean registered,
|
||||||
|
String state,
|
||||||
|
Instant lastHeartbeat,
|
||||||
|
List<String> routeIds,
|
||||||
|
String applicationId,
|
||||||
|
String environmentId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ObservabilityStatusResponse(
|
||||||
|
boolean hasTraces,
|
||||||
|
boolean hasMetrics,
|
||||||
|
boolean hasDiagrams,
|
||||||
|
Instant lastTraceAt,
|
||||||
|
long traceCount24h
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
public record UpdateRoutingRequest(
|
||||||
|
Integer exposedPort
|
||||||
|
) {}
|
||||||
@@ -21,6 +21,7 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -87,6 +88,7 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
|
|||||||
var container = dockerClient.createContainerCmd(request.imageRef())
|
var container = dockerClient.createContainerCmd(request.imageRef())
|
||||||
.withName(request.containerName())
|
.withName(request.containerName())
|
||||||
.withEnv(envList)
|
.withEnv(envList)
|
||||||
|
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||||
.withHostConfig(hostConfig)
|
.withHostConfig(hostConfig)
|
||||||
.withHealthcheck(new HealthCheck()
|
.withHealthcheck(new HealthCheck()
|
||||||
.withTest(List.of("CMD-SHELL",
|
.withTest(List.of("CMD-SHELL",
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class RuntimeConfig {
|
|||||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
||||||
private String cameleer3ServerEndpoint;
|
private String cameleer3ServerEndpoint;
|
||||||
|
|
||||||
|
@Value("${cameleer.runtime.domain:localhost}")
|
||||||
|
private String domain;
|
||||||
|
|
||||||
public long getMaxJarSize() { return maxJarSize; }
|
public long getMaxJarSize() { return maxJarSize; }
|
||||||
public String getJarStoragePath() { return jarStoragePath; }
|
public String getJarStoragePath() { return jarStoragePath; }
|
||||||
public String getBaseImage() { return baseImage; }
|
public String getBaseImage() { return baseImage; }
|
||||||
@@ -50,6 +53,7 @@ public class RuntimeConfig {
|
|||||||
public int getContainerCpuShares() { return containerCpuShares; }
|
public int getContainerCpuShares() { return containerCpuShares; }
|
||||||
public String getBootstrapToken() { return bootstrapToken; }
|
public String getBootstrapToken() { return bootstrapToken; }
|
||||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
|
||||||
public long parseMemoryLimitBytes() {
|
public long parseMemoryLimitBytes() {
|
||||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ public record StartContainerRequest(
|
|||||||
Map<String, String> envVars,
|
Map<String, String> envVars,
|
||||||
long memoryLimitBytes,
|
long memoryLimitBytes,
|
||||||
int cpuShares,
|
int cpuShares,
|
||||||
int healthCheckPort
|
int healthCheckPort,
|
||||||
|
Map<String, String> labels
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -45,5 +45,6 @@ cameleer:
|
|||||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||||
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
clickhouse:
|
clickhouse:
|
||||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AgentStatusServiceTest {
|
||||||
|
|
||||||
|
@Mock private AppRepository appRepository;
|
||||||
|
@Mock private EnvironmentRepository environmentRepository;
|
||||||
|
@Mock private RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
private AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://localhost:9999");
|
||||||
|
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_appNotFound_shouldThrow() {
|
||||||
|
when(appRepository.findById(any())).thenReturn(Optional.empty());
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> agentStatusService.getAgentStatus(UUID.randomUUID()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_shouldReturnUnknownWhenServerUnreachable() {
|
||||||
|
var appId = UUID.randomUUID();
|
||||||
|
var envId = UUID.randomUUID();
|
||||||
|
|
||||||
|
var app = new AppEntity();
|
||||||
|
app.setId(appId);
|
||||||
|
app.setEnvironmentId(envId);
|
||||||
|
app.setSlug("my-app");
|
||||||
|
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||||
|
|
||||||
|
var env = new EnvironmentEntity();
|
||||||
|
env.setId(envId);
|
||||||
|
env.setSlug("default");
|
||||||
|
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||||
|
|
||||||
|
// Server at localhost:9999 won't be running — should return UNKNOWN gracefully
|
||||||
|
var result = agentStatusService.getAgentStatus(appId);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.registered());
|
||||||
|
assertEquals("UNKNOWN", result.state());
|
||||||
|
assertEquals("my-app", result.applicationId());
|
||||||
|
assertEquals("default", result.environmentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getObservabilityStatus_shouldReturnEmptyWhenClickHouseUnavailable() {
|
||||||
|
var appId = UUID.randomUUID();
|
||||||
|
var envId = UUID.randomUUID();
|
||||||
|
|
||||||
|
var app = new AppEntity();
|
||||||
|
app.setId(appId);
|
||||||
|
app.setEnvironmentId(envId);
|
||||||
|
app.setSlug("my-app");
|
||||||
|
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||||
|
|
||||||
|
var env = new EnvironmentEntity();
|
||||||
|
env.setId(envId);
|
||||||
|
env.setSlug("default");
|
||||||
|
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||||
|
|
||||||
|
// No ClickHouse DataSource injected — should return empty status
|
||||||
|
var result = agentStatusService.getObservabilityStatus(appId);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.hasTraces());
|
||||||
|
assertEquals(0, result.traceCount24h());
|
||||||
|
}
|
||||||
|
}
|
||||||
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