48 Commits

Author SHA1 Message Date
hsiegeln
e325c4d2c0 fix: correct Dockerfile frontend build output path
All checks were successful
CI / build (push) Successful in 1m10s
CI / build (pull_request) Successful in 1m9s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 23s
Vite's outDir is '../src/main/resources/static' (relative to ui/),
which resolves to /src/main/resources/static/ in the Docker build.
The COPY was looking at /ui/dist/ which doesn't exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:10:42 +02:00
hsiegeln
4c8c8efbe5 feat: add SPA controller, Traefik route, CI frontend build, and HOWTO update
Some checks failed
CI / build (push) Successful in 49s
CI / docker (push) Failing after 38s
CI / build (pull_request) Successful in 1m2s
CI / docker (pull_request) Has been skipped
- SpaController catch-all forwards non-API routes to index.html
- Traefik SPA route at priority=1 catches all unmatched paths
- CI pipeline builds frontend before Maven
- Dockerfile adds multi-stage frontend build
- HOWTO.md documents frontend development workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:06:36 +02:00
hsiegeln
f6d3627abc feat: add license page with tier features and limits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:04:55 +02:00
hsiegeln
fe786790e1 feat: add app detail page with deploy, logs, and status
Full AppDetailPage with tabbed layout (Overview / Deployments / Logs),
current deployment status with auto-poll, action bar (deploy/stop/restart/re-upload/delete),
agent status card, routing card with edit modal, deployment history table,
and container log viewer with stream filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:03:29 +02:00
hsiegeln
5eac48ad72 feat: add environments list and environment detail pages
Implements EnvironmentsPage with DataTable, create modal, and row navigation,
and EnvironmentDetailPage with app list, inline rename, new app form with JAR
upload, and delete confirmation — all gated by RBAC permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:00:14 +02:00
hsiegeln
02019e9347 feat: add dashboard page with tenant overview and KPI stats
Replaces placeholder DashboardPage with a full implementation: tenant
name + tier badge, KPI strip (environments, total apps, running, stopped),
environment list with per-env app counts, and a recent deployments
placeholder. Uses EnvApps helper component to fetch per-environment app
data without violating hook rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:57:53 +02:00
hsiegeln
91a4235223 feat: add sidebar layout, environment tree, and router
Wires up AppShell + Sidebar compound component, a per-environment
SidebarTree that lazy-fetches apps, React Router nested routes, and
provider-wrapped main.tsx with ThemeProvider/ToastProvider/BreadcrumbProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:55:21 +02:00
hsiegeln
e725669aef feat: add RBAC hooks and permission-gated components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:51:38 +02:00
hsiegeln
d572926010 feat: add API client with auth middleware and React Query hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:50:19 +02:00
hsiegeln
e33818cc74 feat: add auth store, login, callback, and protected route
Adds Zustand auth store with JWT parsing (sub, roles, organization_id),
Logto OIDC login page, authorization code callback handler, and
ProtectedRoute guard. Also adds vite-env.d.ts for import.meta.env types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:48:56 +02:00
hsiegeln
146dbccc6e feat: scaffold React SPA with Vite, design system, and TypeScript types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:47:01 +02:00
hsiegeln
600985c913 docs: add Phase 9 Frontend React Shell spec
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:36:45 +02:00
7aa331d73c Merge pull request 'feat: Phase 4 — Observability Pipeline + Inbound Routing' (#34) from feat/phase-4-observability-pipeline into main
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 3s
Reviewed-on: #34
2026-04-04 21:20:46 +02:00
hsiegeln
9b1643c1ee docs: update HOWTO with observability dashboard, routing, and agent status
All checks were successful
CI / build (push) Successful in 29s
CI / build (pull_request) Successful in 48s
CI / docker (push) Successful in 47s
CI / docker (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:06:05 +02:00
hsiegeln
9f8d0f43ab feat: add dashboard Traefik route and CAMELEER_TENANT_ID config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:04:57 +02:00
hsiegeln
43cd2d012f feat: add cameleer3-server startup connectivity check 2026-04-04 21:03:41 +02:00
hsiegeln
210da55e7a feat: add Traefik routing labels for customer apps with exposed ports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:03:04 +02:00
hsiegeln
08b87edd6e feat: add agent status and observability status endpoints
Implements AgentStatusService (TDD) that proxies cameleer3-server agent
registry API and queries ClickHouse for trace counts. Gracefully degrades
to UNKNOWN state when server is unreachable or DataSource is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:01:43 +02:00
hsiegeln
024780c01e feat: add exposed port routing and route URL to app API
Adds domain config to RuntimeConfig/application.yml, expands AppResponse
with exposedPort and computed routeUrl, adds updateRouting to AppService,
and adds PATCH /{appId}/routing endpoint to AppController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:57:37 +02:00
hsiegeln
d25849d665 feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:55:16 +02:00
hsiegeln
b0275bcf64 feat: add exposed_port column to apps table 2026-04-04 20:53:56 +02:00
hsiegeln
f8d80eaf79 docs: add Phase 4 Observability Pipeline implementation plan
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 4s
8 tasks: migration, labels support, routing API, agent/observability
status endpoints, Traefik routing labels, connectivity check,
Docker Compose + env, HOWTO update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:52:17 +02:00
hsiegeln
41629f3290 docs: add Phase 4 Observability Pipeline + Inbound Routing spec
All checks were successful
CI / build (push) Successful in 27s
CI / docker (push) Successful in 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:47:51 +02:00
hsiegeln
b78dfa9a7b docs: add HOWTO.md with install, start, and bootstrap instructions
All checks were successful
CI / build (push) Successful in 27s
CI / docker (push) Successful in 4s
Quick start, full installation guide, Logto setup, first tenant
creation, app deployment walkthrough, API reference, tier limits,
development commands, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:31:56 +02:00
hsiegeln
d81ce2b697 ci: revert artifact approach, use BuildKit cache for Maven deps
All checks were successful
CI / build (push) Successful in 29s
CI / docker (push) Successful in 2m31s
Gitea Actions doesn't support upload/download-artifact v4.
Reverted to two-job approach (git clone + docker build).
Added BuildKit cache mount (--mount=type=cache,target=/root/.m2)
to Dockerfile so Maven deps persist across Docker builds on the
same runner. First build downloads, subsequent builds are cached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:27:08 +02:00
hsiegeln
cbf7d5c60f ci: pass pre-built JAR to docker job via artifact
Some checks failed
CI / build (push) Failing after 51s
CI / docker (push) Has been skipped
Build job uploads the JAR, docker job downloads it and builds a
runtime-only image. Eliminates duplicate Maven dependency download
(~2min saving). The repo Dockerfile is kept for local builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:15:12 +02:00
956eb13dd6 Merge pull request 'feat: Phase 3 — Runtime Orchestration + Environments' (#33) from feat/phase-3-runtime-orchestration into main
All checks were successful
CI / build (push) Successful in 29s
CI / docker (push) Successful in 45s
Reviewed-on: #33
2026-04-04 18:10:42 +02:00
hsiegeln
af04f7b4a1 ci: add nightly SonarQube analysis workflow
All checks were successful
CI / build (push) Successful in 45s
CI / build (pull_request) Successful in 46s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 2m29s
Runs at 02:00 UTC daily (same schedule as cameleer3 and cameleer3-server).
Uses cameleer-build:1 image, excludes TestContainers integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:08:35 +02:00
hsiegeln
abc06f57da feat: update Docker Compose, CI, and add runtime-base Dockerfile
Some checks failed
CI / build (push) Successful in 57s
CI / build (pull_request) Successful in 54s
CI / docker (pull_request) Has been skipped
CI / docker (push) Has been cancelled
Add jardata volume, CAMELEER_AUTH_TOKEN/CAMELEER3_SERVER_ENDPOINT/CLICKHOUSE_URL env vars to cameleer-saas, CAMELEER_AUTH_TOKEN to cameleer3-server, runtime-base Dockerfile for agent-instrumented customer apps, and expand CI surefire excludes for new integration test classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:04:42 +02:00
hsiegeln
0bd54f2a95 feat: add container log service with ClickHouse storage and log API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:02:42 +02:00
hsiegeln
fc34626a88 feat: add deployment controller with deploy/stop/restart endpoints
Add DeploymentResponse DTO, DeploymentController at /api/apps/{appId} with POST /deploy (202), GET /deployments, GET /deployments/{id}, POST /stop, POST /restart (202), and integration tests covering empty list, 404, and 401 cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:00:23 +02:00
hsiegeln
59df59f406 feat: add deployment service with async pipeline
Implements DeploymentService with TDD: builds Docker images, starts containers with Cameleer env vars, polls for health, and handles stop/restart lifecycle. All 3 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:57:09 +02:00
hsiegeln
23a474fbf3 feat: add deployment entity, repository, and status enums 2026-04-04 17:54:08 +02:00
hsiegeln
d2ea256cd8 feat: add app controller with multipart JAR upload
Adds AppController at /api/environments/{environmentId}/apps with POST (multipart
metadata+JAR), GET list, GET by ID, PUT jar reupload, and DELETE endpoints.
Also adds CreateAppRequest and AppResponse DTOs, integration tests (AppControllerTest),
and fixes ClickHouseConfig to be excluded in test profile via @Profile("!test").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:53:10 +02:00
hsiegeln
51f5822364 feat: add app service with JAR upload and tier enforcement
Implements AppService with JAR file storage, SHA-256 checksum computation,
tier-based app limit enforcement via LicenseDefaults, and audit logging.
Four TDD tests all pass covering creation, JAR validation, duplicate slug
rejection, and JAR re-upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:47:05 +02:00
hsiegeln
2151801d40 feat: add DockerRuntimeOrchestrator with docker-java
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:44:34 +02:00
hsiegeln
90c1e36cb7 feat: add RuntimeOrchestrator interface and request/response types 2026-04-04 17:42:56 +02:00
hsiegeln
731690191b feat: add app entity and repository 2026-04-04 17:42:08 +02:00
hsiegeln
36069bae07 feat: auto-create default environment on tenant provisioning 2026-04-04 17:41:23 +02:00
hsiegeln
785bdab3d1 feat: add environment controller with CRUD endpoints
Implements POST/GET/PATCH/DELETE endpoints at /api/tenants/{tenantId}/environments
with DTOs, mapping helpers, and a Spring Boot integration test (TestContainers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:40:23 +02:00
hsiegeln
34e98ab176 feat: add environment service with tier enforcement and audit logging
Implements EnvironmentService with full CRUD, duplicate slug rejection,
tier-based environment count limits, and audit logging for create/update/delete.
Adds ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE to AuditAction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:36:09 +02:00
hsiegeln
8511d10343 feat: add environment entity, repository, and status enum 2026-04-04 17:33:43 +02:00
hsiegeln
4cb15c9bea feat: add database migrations for environments, apps, deployments 2026-04-04 17:32:51 +02:00
hsiegeln
bd8dfcf147 fix: use concrete ClickHouseDataSource return type to avoid bean ambiguity 2026-04-04 17:32:09 +02:00
hsiegeln
803b8c9876 feat: add Phase 3 dependencies and configuration
Add docker-java and ClickHouse JDBC dependencies, RuntimeConfig and
ClickHouseConfig Spring components, AsyncConfig with deployment thread
pool, and runtime/clickhouse config sections in application.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:29:06 +02:00
hsiegeln
c0fce36d4a chore: add .worktrees to .gitignore 2026-04-04 17:26:22 +02:00
hsiegeln
fa7853b02d docs: add Phase 3 Runtime Orchestration implementation plan
16-task plan covering environments, apps, deployments, Docker
runtime orchestrator, ClickHouse log ingestion, and CI updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:24:20 +02:00
hsiegeln
0326dc6cce docs: add Phase 3 Runtime Orchestration spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:13:08 +02:00
98 changed files with 13762 additions and 6 deletions

View File

@@ -23,3 +23,8 @@ CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub
# Domain (for Traefik TLS)
DOMAIN=localhost
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
CAMELEER_CONTAINER_CPU_SHARES=512
CAMELEER_TENANT_SLUG=default

View File

@@ -27,10 +27,16 @@ jobs:
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build Frontend
run: |
cd ui
npm ci
npm run build
- name: Build and Test (unit tests only)
run: >-
mvn clean verify -B
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java"
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
docker:
needs: build

View File

@@ -0,0 +1,35 @@
name: SonarQube Analysis
on:
schedule:
- cron: '0 2 * * *' # Nightly at 02:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
sonarqube:
runs-on: ubuntu-latest
container:
image: gitea.siegeln.net/cameleer/cameleer-build:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for blame data
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build, Test and Analyze
run: >-
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,**/AgentStatusControllerTest.java"
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
-Dsonar.projectKey=cameleer-saas
-Dsonar.projectName="Cameleer SaaS"

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ Thumbs.db
# Environment
.env
*.env.local
# Worktrees
.worktrees/

View File

@@ -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
WORKDIR /build
COPY .mvn/ .mvn/
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/
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
WORKDIR /app

389
HOWTO.md Normal file
View 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`

View File

@@ -58,6 +58,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./keys:/etc/cameleer/keys:ro
- jardata:/data/jars
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
@@ -69,12 +70,18 @@ services:
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.services.api.loadbalancer.server.port=8080
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
- traefik.http.routers.spa.rule=PathPrefix(`/`)
- traefik.http.routers.spa.priority=1
- traefik.http.services.spa.loadbalancer.server.port=8080
networks:
- cameleer
@@ -89,6 +96,8 @@ services:
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
@@ -96,6 +105,10 @@ services:
- 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.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:
- cameleer
@@ -120,3 +133,4 @@ volumes:
pgdata:
chdata:
acme:
jardata:

View File

@@ -0,0 +1,19 @@
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Agent JAR is copied during CI build from Gitea Maven registry
# ARG AGENT_JAR=cameleer3-agent-1.0-SNAPSHOT-shaded.jar
COPY agent.jar /app/agent.jar
ENTRYPOINT exec java \
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
-Dcameleer.agent.name=${HOSTNAME} \
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
-Dcameleer.health.enabled=true \
-Dcameleer.health.port=9464 \
-javaagent:/app/agent.jar \
-jar /app/app.jar

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,424 @@
# Phase 3: Runtime Orchestration + Environments
**Date:** 2026-04-04
**Status:** Draft
**Depends on:** Phase 2 (Tenants + Identity + Licensing)
**Gitea issue:** #26
## Context
Phase 2 delivered multi-tenancy, identity (Logto OIDC), and license management. The platform can create tenants and issue licenses, but there is nothing to run yet. Phase 3 is the core product differentiator: customers upload a Camel JAR, the platform builds an immutable container image with the cameleer3 agent auto-injected, and deploys it to a logical environment. This is "managed Camel runtime" — similar to Coolify or MuleSoft CloudHub, but purpose-built for Apache Camel with deep observability.
Docker-first. The `KubernetesRuntimeOrchestrator` is deferred to Phase 5.
**Single-node constraint:** Because Phase 3 builds images locally via Docker socket (no registry push), the cameleer-saas control plane and the Docker daemon must reside on the same host. This is inherent to the single-tenant Docker Compose stack and is acceptable for that target. In K8s mode (Phase 5), images are built via Kaniko and pushed to a registry, removing this constraint.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| JAR delivery | Direct HTTP upload (multipart) | Simplest path. Git-based and image-ref options can be added later. |
| Agent JAR source | Bundled in `cameleer-runtime-base` image | Version-locked to platform release. Updated by rebuilding the platform image with the new agent version. No runtime network dependency. |
| Build speed | Pre-built base image + single-layer customer add | Customer image build is `FROM base` + `COPY app.jar`. ~1-3 seconds. |
| Deployment model | Async with polling | Image builds are inherently slow. Deploy returns immediately with deployment ID. Client polls for status. |
| Entity hierarchy | Environment → App → Deployment | User thinks "I'm in dev, deploy my app." Environment is the workspace context. |
| Environment provisioning | Hybrid auto + manual | Every tenant gets a `default` environment on creation. Additional environments created manually, tier limit enforced. |
| Cross-environment isolation | Logical (not network) | Docker single-tenant mode — customer owns the stack. Data separated by `environmentId` in cameleer3-server. Network isolation is a K8s Phase 5 concern. |
| Container networking | Shared `cameleer` bridge network | Customer containers join the existing network. Agent reaches cameleer3-server at `http://cameleer3-server:8081`. |
| Container naming | `{tenant-slug}-{env-slug}-{app-slug}` | Human-readable, unique, identifies tenant+environment+app at a glance. |
| Bootstrap tokens | Shared `CAMELEER_AUTH_TOKEN` from cameleer3-server config | Platform reads the existing token and injects it into customer containers. Environment separation via agent `environmentId` claim, not token. Per-environment tokens deferred to K8s Phase 5. |
| Health checking | Agent health endpoint (port 9464) | Guaranteed to exist, no user config needed. User-defined health endpoints deferred. |
| Inbound HTTP routing | Not in Phase 3 | Most Camel apps are consumers (queues, polls), not servers. Traefik routing for customer apps deferred to Phase 4/4.5. |
| Container logs | Captured via docker-java, written to ClickHouse | Unified log query surface from day 1. Same pattern future app logs will use. |
| Resource constraints | cgroups via docker-java `mem_limit` + `cpu_shares` | Protect the control plane from noisy neighbors. Tier-based defaults. Even in single-tenant Docker mode, a runaway Camel app shouldn't starve Traefik/Postgres/Logto. |
| Orchestrator metadata | JSONB field on deployment entity | Docker stores `containerId`. K8s (Phase 5) stores `namespace`, `deploymentName`, `gitCommit`. Same table, different orchestrator. |
## Data Model
### Environment Entity
```sql
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
bootstrap_token TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
```
- `slug` — URL-safe, immutable, unique per tenant. Auto-created environment gets slug `default`.
- `display_name` — User-editable. Auto-created environment gets `Default`.
- `bootstrap_token` — The `CAMELEER_AUTH_TOKEN` value used for customer containers in this environment. In Docker mode, all environments share the same value (read from platform config). In K8s mode (Phase 5), can be unique per environment.
- `status``ACTIVE` or `SUSPENDED`.
### App Entity
```sql
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
jar_storage_path VARCHAR(500),
jar_checksum VARCHAR(64),
jar_original_filename VARCHAR(255),
jar_size_bytes BIGINT,
current_deployment_id UUID,
previous_deployment_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
```
- `slug` — URL-safe, immutable, unique per environment.
- `jar_storage_path` — Relative path to uploaded JAR (e.g., `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar`). Relative to the configured storage root (`cameleer.runtime.jar-storage-path`). Makes it easy to migrate the storage volume to a different mount point or cloud provider.
- `jar_checksum` — SHA-256 hex digest of the uploaded JAR.
- `current_deployment_id` — Points to the active deployment. Nullable (app created but never deployed).
- `previous_deployment_id` — Points to the last known good deployment. When a new deploy succeeds, `current` becomes the new one and `previous` becomes the old `current`. When a deploy fails, `current` stays as the failed one but `previous` still points to the last good version, enabling a rollback button.
### Deployment Entity
```sql
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
image_ref VARCHAR(500) NOT NULL,
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
orchestrator_metadata JSONB DEFAULT '{}',
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
```
- `version` — Sequential per app (1, 2, 3...). Incremented on each deploy.
- `image_ref` — Docker image reference, e.g., `cameleer-runtime-{tenant}-{app}:v3`.
- `desired_status` — What the user wants: `RUNNING`, `STOPPED`.
- `observed_status` — What the platform sees: `BUILDING`, `STARTING`, `RUNNING`, `FAILED`, `STOPPED`.
- `orchestrator_metadata` — Docker mode: `{"containerId": "abc123"}`. K8s mode (Phase 5): `{"namespace": "...", "deploymentName": "...", "gitCommit": "..."}`.
- `error_message` — Populated when `observed_status` is `FAILED`. Build error, startup crash, etc.
## Component Architecture
### RuntimeOrchestrator Interface
```java
public interface RuntimeOrchestrator {
String buildImage(BuildImageRequest request);
void startContainer(StartContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
void streamLogs(String containerId, LogConsumer consumer);
}
```
- Single interface, implemented by `DockerRuntimeOrchestrator` (Phase 3) and `KubernetesRuntimeOrchestrator` (Phase 5).
- Injected via Spring `@Profile` or `@ConditionalOnProperty`.
- Request objects carry all context (image name, env vars, network, labels, etc.).
### DockerRuntimeOrchestrator
Uses `com.github.docker-java:docker-java` library. Connects via Docker socket (`/var/run/docker.sock`).
**buildImage:**
1. Creates a temporary build context directory
2. Writes a Dockerfile:
```dockerfile
FROM cameleer-runtime-base:{platform-version}
COPY app.jar /app/app.jar
```
3. Copies the customer JAR as `app.jar`
4. Calls `docker build` via docker-java
5. Tags as `cameleer-runtime-{tenant-slug}-{app-slug}:v{version}`
6. Returns the image reference
**startContainer:**
1. Creates container with:
- Image: the built image reference
- Name: `{tenant-slug}-{env-slug}-{app-slug}`
- Network: `cameleer` (the platform bridge network)
- Environment variables:
- `CAMELEER_AUTH_TOKEN={bootstrap-token}`
- `CAMELEER_EXPORT_TYPE=HTTP`
- `CAMELEER_EXPORT_ENDPOINT=http://cameleer3-server:8081`
- `CAMELEER_APPLICATION_ID={app-slug}`
- `CAMELEER_ENVIRONMENT_ID={env-slug}`
- `CAMELEER_DISPLAY_NAME={tenant-slug}-{env-slug}-{app-slug}`
- Resource constraints (cgroups):
- `memory` / `memorySwap` — hard memory limit per container
- `cpuShares` — relative CPU weight (default 512)
- Defaults configurable via `cameleer.runtime.container-memory-limit` (default `512m`) and `cameleer.runtime.container-cpu-shares` (default `512`)
- Protects the control plane (Traefik, Postgres, Logto, cameleer-saas) from noisy neighbor Camel apps
- Health check: HTTP GET to agent health port 9464
2. Starts container
3. Returns container ID
**streamLogs:**
- Attaches to container stdout/stderr via docker-java `LogContainerCmd`
- Passes log lines to a `LogConsumer` callback (for ClickHouse ingestion)
### cameleer-runtime-base Image
A pre-built Docker image containing everything except the customer JAR:
```dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY cameleer3-agent-{version}-shaded.jar /app/agent.jar
ENTRYPOINT exec java \
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
-Dcameleer.agent.name=${HOSTNAME} \
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
-Dcameleer.health.enabled=true \
-Dcameleer.health.port=9464 \
-javaagent:/app/agent.jar \
-jar /app/app.jar
```
- Built as part of the CI pipeline for cameleer-saas.
- Published to Gitea registry: `gitea.siegeln.net/cameleer/cameleer-runtime-base:{version}`.
- Version tracks the platform version + agent version (e.g., `0.2.0` includes agent `1.0-SNAPSHOT`).
- Updating the agent JAR = rebuild this image with the new agent version → rebuild cameleer-saas image → all new deployments use the new agent.
### JAR Upload
- `POST /api/environments/{eid}/apps` with multipart file
- Validation:
- File extension: `.jar`
- Max size: 200 MB (configurable via `cameleer.runtime.max-jar-size`)
- SHA-256 checksum computed and stored
- Storage: relative path `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar` under the configured storage root (`cameleer.runtime.jar-storage-path`, default `/data/jars`)
- Docker volume `jardata` mounted into cameleer-saas container
- Database stores the relative path only — decoupled from mount point
- JAR is overwritten on re-upload (new deploy uses new JAR)
### Async Deployment Pipeline
1. **API receives deploy request** → creates `Deployment` entity with `observed_status=BUILDING` → returns deployment ID (HTTP 202 Accepted)
2. **Background thread** (Spring `@Async` with a bounded thread pool):
a. Calls `orchestrator.buildImage(...)` → updates `observed_status=STARTING`
b. Calls `orchestrator.startContainer(...)` → updates `observed_status=STARTING`
c. Polls agent health endpoint (port 9464) with timeout → updates to `RUNNING` or `FAILED`
d. On any failure → updates `observed_status=FAILED`, `error_message=...`
3. **Client polls** `GET /api/apps/{aid}/deployments/{did}` for status updates
4. **On success:** set `previous_deployment_id = old current_deployment_id`, then `current_deployment_id = new deployment`. Stop and remove the old container.
5. **On failure:** `current_deployment_id` is set to the failed deployment (so status is visible), `previous_deployment_id` still points to the last known good version. Enables rollback.
### Container Logs → ClickHouse
- When a container starts, platform attaches a log consumer via `orchestrator.streamLogs()`
- Log consumer batches lines and writes to ClickHouse table:
```sql
CREATE TABLE IF NOT EXISTS container_logs (
tenant_id UUID,
environment_id UUID,
app_id UUID,
deployment_id UUID,
timestamp DateTime64(3),
stream String, -- 'stdout' or 'stderr'
message String
) ENGINE = MergeTree()
ORDER BY (tenant_id, environment_id, app_id, timestamp);
```
- Logs retrieved via `GET /api/apps/{aid}/logs?since=...&limit=...` which queries ClickHouse
- ClickHouse TTL can enforce retention based on license `retention_days` limit (future enhancement)
### Bootstrap Token Handling
In Docker single-tenant mode, all environments share the single cameleer3-server instance and its single `CAMELEER_AUTH_TOKEN`. The platform reads this token from its own configuration (`cameleer.runtime.bootstrap-token` / `CAMELEER_AUTH_TOKEN` env var) and injects it into every customer container. No changes to cameleer3-server are needed.
Environment-level data separation happens at the agent registration level — the agent sends its `environmentId` claim when it registers, and cameleer3-server uses that to scope all data. The bootstrap token is the same across environments in a Docker stack.
The `bootstrap_token` column on the environment entity stores the token value used for that environment's containers. In Docker mode this is the same shared value for all environments. In K8s mode (Phase 5), each environment could have its own cameleer3-server instance with a unique token, enabling true per-environment token isolation.
## API Surface
### Environment Endpoints
```
POST /api/tenants/{tenantId}/environments
Body: { "slug": "dev", "displayName": "Development" }
Returns: 201 Created + EnvironmentResponse
Enforces: tier-based max_environments limit from license
GET /api/tenants/{tenantId}/environments
Returns: 200 + List<EnvironmentResponse>
GET /api/tenants/{tenantId}/environments/{environmentId}
Returns: 200 + EnvironmentResponse
PATCH /api/tenants/{tenantId}/environments/{environmentId}
Body: { "displayName": "New Name" }
Returns: 200 + EnvironmentResponse
DELETE /api/tenants/{tenantId}/environments/{environmentId}
Returns: 204 No Content
Precondition: no running apps in environment
Restriction: cannot delete the auto-created "default" environment
```
### App Endpoints
```
POST /api/environments/{environmentId}/apps
Multipart: file (JAR) + metadata { "slug": "order-service", "displayName": "Order Service" }
Returns: 201 Created + AppResponse
Validates: file extension, size, checksum
GET /api/environments/{environmentId}/apps
Returns: 200 + List<AppResponse>
GET /api/environments/{environmentId}/apps/{appId}
Returns: 200 + AppResponse (includes current deployment status)
PUT /api/environments/{environmentId}/apps/{appId}/jar
Multipart: file (JAR)
Returns: 200 + AppResponse
Purpose: re-upload JAR without creating new app
DELETE /api/environments/{environmentId}/apps/{appId}
Returns: 204 No Content
Side effect: stops running container, removes image
```
### Deployment Endpoints
```
POST /api/apps/{appId}/deploy
Body: {} (empty — uses current JAR)
Returns: 202 Accepted + DeploymentResponse (with deployment ID, status=BUILDING)
GET /api/apps/{appId}/deployments
Returns: 200 + List<DeploymentResponse> (ordered by version desc)
GET /api/apps/{appId}/deployments/{deploymentId}
Returns: 200 + DeploymentResponse (poll this for status updates)
POST /api/apps/{appId}/stop
Returns: 200 + DeploymentResponse (desired_status=STOPPED)
POST /api/apps/{appId}/restart
Returns: 202 Accepted + DeploymentResponse (stops + redeploys same image)
```
### Log Endpoints
```
GET /api/apps/{appId}/logs
Query: since (ISO timestamp), until (ISO timestamp), limit (default 500), stream (stdout/stderr/both)
Returns: 200 + List<LogEntry>
Source: ClickHouse container_logs table
```
## Tier Enforcement
| Tier | max_environments | max_agents (apps) |
|------|-----------------|-------------------|
| LOW | 1 | 3 |
| MID | 2 | 10 |
| HIGH | unlimited (-1) | 50 |
| BUSINESS | unlimited (-1) | unlimited (-1) |
- `max_environments` enforced on `POST /api/tenants/{tid}/environments`. The auto-created `default` environment counts toward the limit.
- `max_agents` enforced on `POST /api/environments/{eid}/apps`. Count is total apps across all environments in the tenant.
## Docker Compose Changes
The cameleer-saas service needs:
- Docker socket mount: `/var/run/docker.sock:/var/run/docker.sock` (already present in docker-compose.yml)
- JAR storage volume: `jardata:/data/jars`
- `cameleer-runtime-base` image must be available (pre-pulled or built locally)
The cameleer3-server `CAMELEER_AUTH_TOKEN` is read by cameleer-saas from shared environment config and injected into customer containers.
New volume in docker-compose.yml:
```yaml
volumes:
jardata:
```
## Dependencies
### New Maven Dependencies
```xml
<!-- Docker Java client -->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.4.1</version>
</dependency>
<!-- ClickHouse JDBC -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.7.1</version>
<classifier>all</classifier>
</dependency>
```
### New Configuration Properties
```yaml
cameleer:
runtime:
max-jar-size: 209715200 # 200 MB
jar-storage-path: /data/jars
base-image: cameleer-runtime-base:latest
docker-network: cameleer
agent-health-port: 9464
health-check-timeout: 60 # seconds to wait for healthy status
deployment-thread-pool-size: 4
container-memory-limit: 512m # per customer container
container-cpu-shares: 512 # relative weight (default Docker is 1024)
clickhouse:
url: jdbc:clickhouse://clickhouse:8123/cameleer
```
## Verification Plan
1. Upload a sample Camel JAR via `POST /api/environments/{eid}/apps`
2. Deploy via `POST /api/apps/{aid}/deploy` — returns 202 with deployment ID
3. Poll `GET /api/apps/{aid}/deployments/{did}` — status transitions: `BUILDING` → `STARTING` → `RUNNING`
4. Container visible in `docker ps` as `{tenant}-{env}-{app}`
5. Container is on the `cameleer` network
6. cameleer3 agent registers with cameleer3-server (visible in server logs)
7. Agent health endpoint responds on port 9464
8. Container logs appear in ClickHouse `container_logs` table
9. `GET /api/apps/{aid}/logs` returns log entries
10. `POST /api/apps/{aid}/stop` stops the container, status becomes `STOPPED`
11. `POST /api/apps/{aid}/restart` restarts with same image
12. Re-upload JAR + redeploy creates deployment v2, stops v1
13. Tier limits enforced: LOW tenant cannot create more than 1 environment or 3 apps
14. Default environment auto-created on tenant provisioning

View File

@@ -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)

View File

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

20
pom.xml
View File

@@ -80,6 +80,26 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Docker Java client -->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.4.1</version>
</dependency>
<!-- ClickHouse JDBC -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.7.1</version>
<classifier>all</classifier>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,163 @@
package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.app.dto.AppResponse;
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.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PostMapping;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/environments/{environmentId}/apps")
public class AppController {
private final AppService appService;
private final ObjectMapper objectMapper;
private final EnvironmentService environmentService;
private final RuntimeConfig runtimeConfig;
private final TenantRepository tenantRepository;
public AppController(AppService appService, ObjectMapper objectMapper,
EnvironmentService environmentService,
RuntimeConfig runtimeConfig,
TenantRepository tenantRepository) {
this.appService = appService;
this.objectMapper = objectMapper;
this.environmentService = environmentService;
this.runtimeConfig = runtimeConfig;
this.tenantRepository = tenantRepository;
}
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<AppResponse> create(
@PathVariable UUID environmentId,
@RequestPart("metadata") String metadataJson,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
try {
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
UUID actorId = resolveActorId(authentication);
var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
var msg = e.getMessage();
if (msg != null && (msg.contains("already exists") || msg.contains("slug"))) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} catch (IOException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
var apps = appService.listByEnvironmentId(environmentId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(apps);
}
@GetMapping("/{appId}")
public ResponseEntity<AppResponse> getById(
@PathVariable UUID environmentId,
@PathVariable UUID appId) {
return appService.getById(appId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
public ResponseEntity<AppResponse> reuploadJar(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = appService.reuploadJar(appId, file, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{appId}")
public ResponseEntity<Void> delete(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
appService.delete(appId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@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) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
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(
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());
}
}

View File

@@ -0,0 +1,86 @@
package net.siegeln.cameleer.saas.app;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "apps")
public class AppEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "environment_id", nullable = false)
private UUID environmentId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "jar_storage_path", length = 500)
private String jarStoragePath;
@Column(name = "jar_checksum", length = 64)
private String jarChecksum;
@Column(name = "jar_original_filename")
private String jarOriginalFilename;
@Column(name = "jar_size_bytes")
private Long jarSizeBytes;
@Column(name = "current_deployment_id")
private UUID currentDeploymentId;
@Column(name = "previous_deployment_id")
private UUID previousDeploymentId;
@Column(name = "exposed_port")
private Integer exposedPort;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getEnvironmentId() { return environmentId; }
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getJarStoragePath() { return jarStoragePath; }
public void setJarStoragePath(String jarStoragePath) { this.jarStoragePath = jarStoragePath; }
public String getJarChecksum() { return jarChecksum; }
public void setJarChecksum(String jarChecksum) { this.jarChecksum = jarChecksum; }
public String getJarOriginalFilename() { return jarOriginalFilename; }
public void setJarOriginalFilename(String jarOriginalFilename) { this.jarOriginalFilename = jarOriginalFilename; }
public Long getJarSizeBytes() { return jarSizeBytes; }
public void setJarSizeBytes(Long jarSizeBytes) { this.jarSizeBytes = jarSizeBytes; }
public UUID getCurrentDeploymentId() { return currentDeploymentId; }
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
public UUID getPreviousDeploymentId() { return 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 getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,24 @@
package net.siegeln.cameleer.saas.app;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AppRepository extends JpaRepository<AppEntity, UUID> {
List<AppEntity> findByEnvironmentId(UUID environmentId);
Optional<AppEntity> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
boolean existsByEnvironmentIdAndSlug(UUID environmentId, String slug);
@Query("SELECT COUNT(a) FROM AppEntity a JOIN EnvironmentEntity e ON a.environmentId = e.id WHERE e.tenantId = :tenantId")
long countByTenantId(UUID tenantId);
long countByEnvironmentId(UUID environmentId);
}

View File

@@ -0,0 +1,168 @@
package net.siegeln.cameleer.saas.app;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class AppService {
private final AppRepository appRepository;
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final RuntimeConfig runtimeConfig;
public AppService(AppRepository appRepository,
EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService,
RuntimeConfig runtimeConfig) {
this.appRepository = appRepository;
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.runtimeConfig = runtimeConfig;
}
public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var env = environmentRepository.findById(envId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
if (appRepository.existsByEnvironmentIdAndSlug(envId, slug)) {
throw new IllegalArgumentException("App slug already exists in this environment: " + slug);
}
var tenantId = env.getTenantId();
enforceTierLimit(tenantId);
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
var entity = new AppEntity();
entity.setEnvironmentId(envId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setJarStoragePath(relativePath);
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
var saved = appRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.APP_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
var checksum = storeJar(jarFile, entity.getJarStoragePath());
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
return appRepository.save(entity);
}
public List<AppEntity> listByEnvironmentId(UUID envId) {
return appRepository.findByEnvironmentId(envId);
}
public Optional<AppEntity> getById(UUID id) {
return appRepository.findById(id);
}
public void delete(UUID appId, UUID actorId) {
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
appRepository.delete(entity);
var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_DELETE, entity.getSlug(),
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) {
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
}
private void validateJarFile(MultipartFile jarFile) {
var filename = jarFile.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".jar")) {
throw new IllegalArgumentException("File must be a .jar file");
}
if (jarFile.getSize() > runtimeConfig.getMaxJarSize()) {
throw new IllegalArgumentException("JAR file exceeds maximum allowed size");
}
}
private String storeJar(MultipartFile file, String relativePath) {
try {
var targetPath = resolveJarPath(relativePath);
Files.createDirectories(targetPath.getParent());
Files.copy(file.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return computeSha256(file);
} catch (IOException e) {
throw new IllegalStateException("Failed to store JAR file", e);
}
}
private String computeSha256(MultipartFile file) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hash = digest.digest(file.getBytes());
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException | IOException e) {
throw new IllegalStateException("Failed to compute SHA-256 checksum", e);
}
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxApps = (int) limits.getOrDefault("max_agents", 3);
if (maxApps != -1 && appRepository.countByTenantId(tenantId) >= maxApps) {
throw new IllegalStateException("App limit reached for current tier");
}
}
}

View File

@@ -0,0 +1,20 @@
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
) {}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.app.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateAppRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.audit;
public enum AuditAction {
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
CONFIG_UPDATE,

View File

@@ -0,0 +1,32 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
private final RuntimeConfig runtimeConfig;
public AsyncConfig(RuntimeConfig runtimeConfig) {
this.runtimeConfig = runtimeConfig;
}
@Bean(name = "deploymentExecutor")
public Executor deploymentExecutor() {
var executor = new ThreadPoolTaskExecutor();
// Core == max: no burst threads. Deployments beyond pool size queue (up to 25).
executor.setCorePoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setMaxPoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
}

View File

@@ -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";
}
}

View File

@@ -0,0 +1,113 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}")
public class DeploymentController {
private final DeploymentService deploymentService;
public DeploymentController(DeploymentService deploymentService) {
this.deploymentService = deploymentService;
}
@PostMapping("/deploy")
public ResponseEntity<DeploymentResponse> deploy(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.deploy(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/deployments")
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
var deployments = deploymentService.listByAppId(appId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(deployments);
}
@GetMapping("/deployments/{deploymentId}")
public ResponseEntity<DeploymentResponse> getDeployment(
@PathVariable UUID appId,
@PathVariable UUID deploymentId) {
return deploymentService.getById(deploymentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/stop")
public ResponseEntity<DeploymentResponse> stop(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.stop(appId, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/restart")
public ResponseEntity<DeploymentResponse> restart(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.restart(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private DeploymentResponse toResponse(DeploymentEntity entity) {
return new DeploymentResponse(
entity.getId(),
entity.getAppId(),
entity.getVersion(),
entity.getImageRef(),
entity.getDesiredStatus().name(),
entity.getObservedStatus().name(),
entity.getErrorMessage(),
entity.getOrchestratorMetadata(),
entity.getDeployedAt(),
entity.getStoppedAt(),
entity.getCreatedAt()
);
}
}

View File

@@ -0,0 +1,88 @@
package net.siegeln.cameleer.saas.deployment;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "deployments")
public class DeploymentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "app_id", nullable = false)
private UUID appId;
@Column(nullable = false)
private int version;
@Column(name = "image_ref", nullable = false, length = 500)
private String imageRef;
@Enumerated(EnumType.STRING)
@Column(name = "desired_status", nullable = false, length = 20)
private DesiredStatus desiredStatus = DesiredStatus.RUNNING;
@Enumerated(EnumType.STRING)
@Column(name = "observed_status", nullable = false, length = 20)
private ObservedStatus observedStatus = ObservedStatus.BUILDING;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "orchestrator_metadata")
private Map<String, Object> orchestratorMetadata = Map.of();
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "deployed_at")
private Instant deployedAt;
@Column(name = "stopped_at")
private Instant stoppedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAppId() { return appId; }
public void setAppId(UUID appId) { this.appId = appId; }
public int getVersion() { return version; }
public void setVersion(int version) { this.version = version; }
public String getImageRef() { return imageRef; }
public void setImageRef(String imageRef) { this.imageRef = imageRef; }
public DesiredStatus getDesiredStatus() { return desiredStatus; }
public void setDesiredStatus(DesiredStatus desiredStatus) { this.desiredStatus = desiredStatus; }
public ObservedStatus getObservedStatus() { return observedStatus; }
public void setObservedStatus(ObservedStatus observedStatus) { this.observedStatus = observedStatus; }
public Map<String, Object> getOrchestratorMetadata() { return orchestratorMetadata; }
public void setOrchestratorMetadata(Map<String, Object> orchestratorMetadata) { this.orchestratorMetadata = orchestratorMetadata; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Instant getDeployedAt() { return deployedAt; }
public void setDeployedAt(Instant deployedAt) { this.deployedAt = deployedAt; }
public Instant getStoppedAt() { return stoppedAt; }
public void setStoppedAt(Instant stoppedAt) { this.stoppedAt = stoppedAt; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,19 @@
package net.siegeln.cameleer.saas.deployment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface DeploymentRepository extends JpaRepository<DeploymentEntity, UUID> {
List<DeploymentEntity> findByAppIdOrderByVersionDesc(UUID appId);
@Query("SELECT COALESCE(MAX(d.version), 0) FROM DeploymentEntity d WHERE d.appId = :appId")
int findMaxVersionByAppId(UUID appId);
Optional<DeploymentEntity> findByAppIdAndVersion(UUID appId, int version);
}

View File

@@ -0,0 +1,254 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.app.AppService;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import net.siegeln.cameleer.saas.runtime.StartContainerRequest;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deploymentRepository;
private final AppRepository appRepository;
private final AppService appService;
private final EnvironmentRepository environmentRepository;
private final TenantRepository tenantRepository;
private final RuntimeOrchestrator runtimeOrchestrator;
private final RuntimeConfig runtimeConfig;
private final AuditService auditService;
public DeploymentService(DeploymentRepository deploymentRepository,
AppRepository appRepository,
AppService appService,
EnvironmentRepository environmentRepository,
TenantRepository tenantRepository,
RuntimeOrchestrator runtimeOrchestrator,
RuntimeConfig runtimeConfig,
AuditService auditService) {
this.deploymentRepository = deploymentRepository;
this.appRepository = appRepository;
this.appService = appService;
this.environmentRepository = environmentRepository;
this.tenantRepository = tenantRepository;
this.runtimeOrchestrator = runtimeOrchestrator;
this.runtimeConfig = runtimeConfig;
this.auditService = auditService;
}
public DeploymentEntity deploy(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getJarStoragePath() == null) {
throw new IllegalStateException("App has no JAR uploaded: " + appId);
}
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
int nextVersion = deploymentRepository.findMaxVersionByAppId(appId) + 1;
var imageRef = "cameleer-runtime-" + env.getSlug() + "-" + app.getSlug() + ":v" + nextVersion;
var deployment = new DeploymentEntity();
deployment.setAppId(appId);
deployment.setVersion(nextVersion);
deployment.setImageRef(imageRef);
deployment.setObservedStatus(ObservedStatus.BUILDING);
deployment.setDesiredStatus(DesiredStatus.RUNNING);
var saved = deploymentRepository.save(deployment);
auditService.log(actorId, null, env.getTenantId(),
AuditAction.APP_DEPLOY, app.getSlug(),
env.getSlug(), null, "SUCCESS", null);
executeDeploymentAsync(saved.getId(), app, env);
return saved;
}
@Async("deploymentExecutor")
public void executeDeploymentAsync(UUID deploymentId, AppEntity app, EnvironmentEntity env) {
var deployment = deploymentRepository.findById(deploymentId).orElse(null);
if (deployment == null) {
log.error("Deployment not found for async execution: {}", deploymentId);
return;
}
try {
var jarPath = appService.resolveJarPath(app.getJarStoragePath());
runtimeOrchestrator.buildImage(new BuildImageRequest(
runtimeConfig.getBaseImage(),
jarPath,
deployment.getImageRef()
));
deployment.setObservedStatus(ObservedStatus.STARTING);
deploymentRepository.save(deployment);
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString();
var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug();
if (app.getCurrentDeploymentId() != null) {
deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> {
var oldMetadata = oldDeployment.getOrchestratorMetadata();
if (oldMetadata != null && oldMetadata.containsKey("containerId")) {
var oldContainerId = (String) oldMetadata.get("containerId");
try {
runtimeOrchestrator.stopContainer(oldContainerId);
} catch (Exception e) {
log.warn("Failed to stop old container {}: {}", oldContainerId, e.getMessage());
}
}
});
}
// 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(
deployment.getImageRef(),
containerName,
runtimeConfig.getDockerNetwork(),
Map.of(
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
"CAMELEER_EXPORT_TYPE", "HTTP",
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
"CAMELEER_APPLICATION_ID", app.getSlug(),
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
"CAMELEER_DISPLAY_NAME", containerName
),
runtimeConfig.parseMemoryLimitBytes(),
runtimeConfig.getContainerCpuShares(),
runtimeConfig.getAgentHealthPort(),
labels
));
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
deploymentRepository.save(deployment);
boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout());
var previousDeploymentId = app.getCurrentDeploymentId();
if (healthy) {
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setDeployedAt(Instant.now());
deploymentRepository.save(deployment);
app.setPreviousDeploymentId(previousDeploymentId);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
} else {
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage("Container did not become healthy within timeout");
deploymentRepository.save(deployment);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
}
} catch (Exception e) {
log.error("Deployment {} failed: {}", deploymentId, e.getMessage(), e);
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage(e.getMessage());
deploymentRepository.save(deployment);
}
}
public DeploymentEntity stop(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getCurrentDeploymentId() == null) {
throw new IllegalStateException("App has no active deployment: " + appId);
}
var deployment = deploymentRepository.findById(app.getCurrentDeploymentId())
.orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + app.getCurrentDeploymentId()));
var metadata = deployment.getOrchestratorMetadata();
if (metadata != null && metadata.containsKey("containerId")) {
var containerId = (String) metadata.get("containerId");
runtimeOrchestrator.stopContainer(containerId);
}
deployment.setDesiredStatus(DesiredStatus.STOPPED);
deployment.setObservedStatus(ObservedStatus.STOPPED);
deployment.setStoppedAt(Instant.now());
var saved = deploymentRepository.save(deployment);
var env = environmentRepository.findById(app.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_STOP, app.getSlug(),
env != null ? env.getSlug() : null, null, "SUCCESS", null);
return saved;
}
public DeploymentEntity restart(UUID appId, UUID actorId) {
stop(appId, actorId);
return deploy(appId, actorId);
}
public List<DeploymentEntity> listByAppId(UUID appId) {
return deploymentRepository.findByAppIdOrderByVersionDesc(appId);
}
public Optional<DeploymentEntity> getById(UUID deploymentId) {
return deploymentRepository.findById(deploymentId);
}
boolean waitForHealthy(String containerId, int timeoutSeconds) {
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {
var status = runtimeOrchestrator.getContainerStatus(containerId);
if (!status.running()) {
return false;
}
if ("healthy".equals(status.state())) {
return true;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.deployment;
public enum DesiredStatus {
RUNNING, STOPPED
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.deployment;
public enum ObservedStatus {
BUILDING, STARTING, RUNNING, FAILED, STOPPED
}

View File

@@ -0,0 +1,12 @@
package net.siegeln.cameleer.saas.deployment.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record DeploymentResponse(
UUID id, UUID appId, int version, String imageRef,
String desiredStatus, String observedStatus, String errorMessage,
Map<String, Object> orchestratorMetadata,
Instant deployedAt, Instant stoppedAt, Instant createdAt
) {}

View File

@@ -0,0 +1,117 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/environments")
public class EnvironmentController {
private final EnvironmentService environmentService;
public EnvironmentController(EnvironmentService environmentService) {
this.environmentService = environmentService;
}
@PostMapping
public ResponseEntity<EnvironmentResponse> create(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@GetMapping
public ResponseEntity<List<EnvironmentResponse>> list(@PathVariable UUID tenantId) {
var environments = environmentService.listByTenantId(tenantId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(environments);
}
@GetMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> getById(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId) {
return environmentService.getById(environmentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PatchMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> update(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
@Valid @RequestBody UpdateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{environmentId}")
public ResponseEntity<Void> delete(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private EnvironmentResponse toResponse(EnvironmentEntity entity) {
return new EnvironmentResponse(
entity.getId(),
entity.getTenantId(),
entity.getSlug(),
entity.getDisplayName(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -0,0 +1,62 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "environments")
public class EnvironmentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
private String bootstrapToken;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EnvironmentStatus status = EnvironmentStatus.ACTIVE;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getBootstrapToken() { return bootstrapToken; }
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
public EnvironmentStatus getStatus() { return status; }
public void setStatus(EnvironmentStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.environment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface EnvironmentRepository extends JpaRepository<EnvironmentEntity, UUID> {
List<EnvironmentEntity> findByTenantId(UUID tenantId);
Optional<EnvironmentEntity> findByTenantIdAndSlug(UUID tenantId, String slug);
long countByTenantId(UUID tenantId);
boolean existsByTenantIdAndSlug(UUID tenantId, String slug);
}

View File

@@ -0,0 +1,109 @@
package net.siegeln.cameleer.saas.environment;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class EnvironmentService {
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final RuntimeConfig runtimeConfig;
public EnvironmentService(EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService,
RuntimeConfig runtimeConfig) {
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.runtimeConfig = runtimeConfig;
}
public EnvironmentEntity create(UUID tenantId, String slug, String displayName, UUID actorId) {
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
}
enforceTierLimit(tenantId);
var entity = new EnvironmentEntity();
entity.setTenantId(tenantId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setBootstrapToken(runtimeConfig.getBootstrapToken());
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.ENVIRONMENT_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
.orElseGet(() -> create(tenantId, "default", "Default", null));
}
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
return environmentRepository.findByTenantId(tenantId);
}
public Optional<EnvironmentEntity> getById(UUID id) {
return environmentRepository.findById(id);
}
public EnvironmentEntity updateDisplayName(UUID environmentId, String displayName, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
entity.setDisplayName(displayName);
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_UPDATE, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public void delete(UUID environmentId, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
if ("default".equals(entity.getSlug())) {
throw new IllegalStateException("Cannot delete the default environment");
}
environmentRepository.delete(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxEnvs = (int) limits.getOrDefault("max_environments", 1);
var currentCount = environmentRepository.countByTenantId(tenantId);
if (maxEnvs != -1 && currentCount >= maxEnvs) {
throw new IllegalStateException("Environment limit reached for current tier");
}
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.environment;
public enum EnvironmentStatus {
ACTIVE, SUSPENDED
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateEnvironmentRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.environment.dto;
import java.time.Instant;
import java.util.UUID;
public record EnvironmentResponse(
UUID id,
UUID tenantId,
String slug,
String displayName,
String status,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -0,0 +1,9 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateEnvironmentRequest(
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -0,0 +1,23 @@
package net.siegeln.cameleer.saas.log;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import com.clickhouse.jdbc.ClickHouseDataSource;
import java.util.Properties;
@Configuration
@Profile("!test")
public class ClickHouseConfig {
@Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}")
private String url;
@Bean(name = "clickHouseDataSource")
public ClickHouseDataSource clickHouseDataSource() throws Exception {
var properties = new Properties();
return new ClickHouseDataSource(url, properties);
}
}

View File

@@ -0,0 +1,137 @@
package net.siegeln.cameleer.saas.log;
import net.siegeln.cameleer.saas.log.dto.LogEntry;
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 javax.sql.DataSource;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
@Service
public class ContainerLogService {
private static final Logger log = LoggerFactory.getLogger(ContainerLogService.class);
private static final int FLUSH_THRESHOLD = 100;
private final DataSource clickHouseDataSource;
private final ConcurrentLinkedQueue<Object[]> buffer = new ConcurrentLinkedQueue<>();
@Autowired
public ContainerLogService(
@Autowired(required = false) @Qualifier("clickHouseDataSource") DataSource clickHouseDataSource) {
this.clickHouseDataSource = clickHouseDataSource;
if (clickHouseDataSource == null) {
log.warn("ClickHouse data source not available — ContainerLogService running in no-op mode");
} else {
initSchema();
}
}
void initSchema() {
if (clickHouseDataSource == null) return;
try (var conn = clickHouseDataSource.getConnection();
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS container_logs (
tenant_id UUID,
environment_id UUID,
app_id UUID,
deployment_id UUID,
timestamp DateTime64(3),
stream String,
message String
) ENGINE = MergeTree()
ORDER BY (tenant_id, environment_id, app_id, timestamp)
""");
} catch (Exception e) {
log.error("Failed to initialize ClickHouse schema", e);
}
}
public void write(UUID tenantId, UUID envId, UUID appId, UUID deploymentId,
String stream, String message, long timestampMillis) {
if (clickHouseDataSource == null) return;
buffer.add(new Object[]{tenantId, envId, appId, deploymentId, timestampMillis, stream, message});
if (buffer.size() >= FLUSH_THRESHOLD) {
flush();
}
}
public void flush() {
if (clickHouseDataSource == null || buffer.isEmpty()) return;
List<Object[]> batch = new ArrayList<>(FLUSH_THRESHOLD);
Object[] row;
while ((row = buffer.poll()) != null) {
batch.add(row);
}
if (batch.isEmpty()) return;
String sql = "INSERT INTO container_logs (tenant_id, environment_id, app_id, deployment_id, timestamp, stream, message) VALUES (?, ?, ?, ?, ?, ?, ?)";
try (var conn = clickHouseDataSource.getConnection();
var ps = conn.prepareStatement(sql)) {
for (Object[] entry : batch) {
ps.setObject(1, entry[0]); // tenant_id
ps.setObject(2, entry[1]); // environment_id
ps.setObject(3, entry[2]); // app_id
ps.setObject(4, entry[3]); // deployment_id
ps.setTimestamp(5, new Timestamp((Long) entry[4]));
ps.setString(6, (String) entry[5]);
ps.setString(7, (String) entry[6]);
ps.addBatch();
}
ps.executeBatch();
} catch (Exception e) {
log.error("Failed to flush log batch to ClickHouse ({} entries)", batch.size(), e);
}
}
public List<LogEntry> query(UUID appId, Instant since, Instant until, int limit, String stream) {
if (clickHouseDataSource == null) return List.of();
StringBuilder sql = new StringBuilder(
"SELECT app_id, deployment_id, timestamp, stream, message FROM container_logs WHERE app_id = ?");
List<Object> params = new ArrayList<>();
params.add(appId);
if (since != null) {
sql.append(" AND timestamp >= ?");
params.add(Timestamp.from(since));
}
if (until != null) {
sql.append(" AND timestamp <= ?");
params.add(Timestamp.from(until));
}
if (stream != null && !"both".equalsIgnoreCase(stream)) {
sql.append(" AND stream = ?");
params.add(stream);
}
sql.append(" ORDER BY timestamp LIMIT ?");
params.add(limit);
List<LogEntry> results = new ArrayList<>();
try (var conn = clickHouseDataSource.getConnection();
var ps = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
try (var rs = ps.executeQuery()) {
while (rs.next()) {
results.add(new LogEntry(
UUID.fromString(rs.getString("app_id")),
UUID.fromString(rs.getString("deployment_id")),
rs.getTimestamp("timestamp").toInstant(),
rs.getString("stream"),
rs.getString("message")
));
}
}
} catch (Exception e) {
log.error("Failed to query container logs for appId={}", appId, e);
}
return results;
}
}

View File

@@ -0,0 +1,36 @@
package net.siegeln.cameleer.saas.log;
import net.siegeln.cameleer.saas.log.dto.LogEntry;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}/logs")
public class LogController {
private final ContainerLogService containerLogService;
public LogController(ContainerLogService containerLogService) {
this.containerLogService = containerLogService;
}
@GetMapping
public ResponseEntity<List<LogEntry>> query(
@PathVariable UUID appId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
@RequestParam(defaultValue = "500") int limit,
@RequestParam(defaultValue = "both") String stream) {
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
return ResponseEntity.ok(entries);
}
}

View File

@@ -0,0 +1,8 @@
package net.siegeln.cameleer.saas.log.dto;
import java.time.Instant;
import java.util.UUID;
public record LogEntry(
UUID appId, UUID deploymentId, Instant timestamp, String stream, String message
) {}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.observability.dto;
public record UpdateRoutingRequest(
Integer exposedPort
) {}

View File

@@ -0,0 +1,9 @@
package net.siegeln.cameleer.saas.runtime;
import java.nio.file.Path;
public record BuildImageRequest(
String baseImage,
Path jarPath,
String imageTag
) {}

View File

@@ -0,0 +1,8 @@
package net.siegeln.cameleer.saas.runtime;
public record ContainerStatus(
String state,
boolean running,
int exitCode,
String error
) {}

View File

@@ -0,0 +1,169 @@
package net.siegeln.cameleer.saas.runtime;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.BuildImageResultCallback;
import com.github.dockerjava.api.model.*;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
private DockerClient dockerClient;
@PostConstruct
public void init() {
var config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
var httpClient = new ApacheDockerHttpClient.Builder()
.dockerHost(config.getDockerHost())
.build();
dockerClient = DockerClientImpl.getInstance(config, httpClient);
log.info("Docker client initialized, host: {}", config.getDockerHost());
}
@PreDestroy
public void close() throws IOException {
if (dockerClient != null) {
dockerClient.close();
}
}
@Override
public String buildImage(BuildImageRequest request) {
Path buildDir = null;
try {
buildDir = Files.createTempDirectory("cameleer-build-");
var dockerfile = buildDir.resolve("Dockerfile");
Files.writeString(dockerfile,
"FROM " + request.baseImage() + "\nCOPY app.jar /app/app.jar\n");
Files.copy(request.jarPath(), buildDir.resolve("app.jar"), StandardCopyOption.REPLACE_EXISTING);
var imageId = dockerClient.buildImageCmd(buildDir.toFile())
.withTags(Set.of(request.imageTag()))
.exec(new BuildImageResultCallback())
.awaitImageId();
log.info("Built image {} -> {}", request.imageTag(), imageId);
return imageId;
} catch (IOException e) {
throw new RuntimeException("Failed to build image: " + e.getMessage(), e);
} finally {
if (buildDir != null) {
deleteDirectory(buildDir);
}
}
}
@Override
public String startContainer(StartContainerRequest request) {
var envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.toList();
var hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network());
var container = dockerClient.createContainerCmd(request.imageRef())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels() != null ? request.labels() : Map.of())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/health || exit 1"))
.withInterval(10_000_000_000L) // 10s
.withTimeout(5_000_000_000L) // 5s
.withRetries(3)
.withStartPeriod(30_000_000_000L)) // 30s
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
log.info("Started container {} ({})", request.containerName(), container.getId());
return container.getId();
}
@Override
public void stopContainer(String containerId) {
try {
dockerClient.stopContainerCmd(containerId).withTimeout(30).exec();
log.info("Stopped container {}", containerId);
} catch (Exception e) {
log.warn("Failed to stop container {}: {}", containerId, e.getMessage());
}
}
@Override
public void removeContainer(String containerId) {
try {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
log.info("Removed container {}", containerId);
} catch (Exception e) {
log.warn("Failed to remove container {}: {}", containerId, e.getMessage());
}
}
@Override
public ContainerStatus getContainerStatus(String containerId) {
try {
var inspection = dockerClient.inspectContainerCmd(containerId).exec();
var state = inspection.getState();
return new ContainerStatus(
state.getStatus(),
Boolean.TRUE.equals(state.getRunning()),
state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0,
state.getError());
} catch (Exception e) {
return new ContainerStatus("not_found", false, -1, e.getMessage());
}
}
@Override
public void streamLogs(String containerId, LogConsumer consumer) {
dockerClient.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withFollowStream(true)
.withTimestamps(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
var stream = frame.getStreamType() == StreamType.STDERR ? "stderr" : "stdout";
consumer.accept(stream, new String(frame.getPayload()).trim(),
System.currentTimeMillis());
}
});
}
private void deleteDirectory(Path dir) {
try {
Files.walk(dir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch (IOException e) {
log.warn("Failed to clean up build directory: {}", dir, e);
}
}
}

View File

@@ -0,0 +1,6 @@
package net.siegeln.cameleer.saas.runtime;
@FunctionalInterface
public interface LogConsumer {
void accept(String stream, String message, long timestampMillis);
}

View File

@@ -0,0 +1,67 @@
package net.siegeln.cameleer.saas.runtime;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class RuntimeConfig {
@Value("${cameleer.runtime.max-jar-size:209715200}")
private long maxJarSize;
@Value("${cameleer.runtime.jar-storage-path:/data/jars}")
private String jarStoragePath;
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
private String baseImage;
@Value("${cameleer.runtime.docker-network:cameleer}")
private String dockerNetwork;
@Value("${cameleer.runtime.agent-health-port:9464}")
private int agentHealthPort;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@Value("${cameleer.runtime.deployment-thread-pool-size:4}")
private int deploymentThreadPoolSize;
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String containerMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int containerCpuShares;
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
private String bootstrapToken;
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
private String cameleer3ServerEndpoint;
@Value("${cameleer.runtime.domain:localhost}")
private String domain;
public long getMaxJarSize() { return maxJarSize; }
public String getJarStoragePath() { return jarStoragePath; }
public String getBaseImage() { return baseImage; }
public String getDockerNetwork() { return dockerNetwork; }
public int getAgentHealthPort() { return agentHealthPort; }
public int getHealthCheckTimeout() { return healthCheckTimeout; }
public int getDeploymentThreadPoolSize() { return deploymentThreadPoolSize; }
public String getContainerMemoryLimit() { return containerMemoryLimit; }
public int getContainerCpuShares() { return containerCpuShares; }
public String getBootstrapToken() { return bootstrapToken; }
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
public String getDomain() { return domain; }
public long parseMemoryLimitBytes() {
var limit = containerMemoryLimit.trim().toLowerCase();
if (limit.endsWith("g")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024 * 1024;
} else if (limit.endsWith("m")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(limit);
}
}

View File

@@ -0,0 +1,10 @@
package net.siegeln.cameleer.saas.runtime;
public interface RuntimeOrchestrator {
String buildImage(BuildImageRequest request);
String startContainer(StartContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
void streamLogs(String containerId, LogConsumer consumer);
}

View File

@@ -0,0 +1,14 @@
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
) {}

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;
@@ -16,11 +17,13 @@ public class TenantService {
private final TenantRepository tenantRepository;
private final AuditService auditService;
private final LogtoManagementClient logtoClient;
private final EnvironmentService environmentService;
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient, EnvironmentService environmentService) {
this.tenantRepository = tenantRepository;
this.auditService = auditService;
this.logtoClient = logtoClient;
this.environmentService = environmentService;
}
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
@@ -44,6 +47,8 @@ public class TenantService {
}
}
environmentService.createDefaultForTenant(saved.getId());
auditService.log(actorId, null, saved.getId(),
AuditAction.TENANT_CREATE, saved.getSlug(),
null, null, "SUCCESS", null);

View File

@@ -33,3 +33,18 @@ cameleer:
logto-endpoint: ${LOGTO_ENDPOINT:}
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
runtime:
max-jar-size: 209715200
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest}
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
agent-health-port: 9464
health-check-timeout: 60
deployment-thread-pool-size: 4
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
domain: ${DOMAIN:localhost}
clickhouse:
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}

View File

@@ -0,0 +1,13 @@
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
bootstrap_token TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);

View File

@@ -0,0 +1,17 @@
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
jar_storage_path VARCHAR(500),
jar_checksum VARCHAR(64),
jar_original_filename VARCHAR(255),
jar_size_bytes BIGINT,
current_deployment_id UUID,
previous_deployment_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);

View File

@@ -0,0 +1,16 @@
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
image_ref VARCHAR(500) NOT NULL,
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
orchestrator_metadata JSONB DEFAULT '{}',
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);

View File

@@ -0,0 +1 @@
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;

View File

@@ -0,0 +1,169 @@
package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class AppControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private AppRepository appRepository;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID environmentId;
@BeforeEach
void setUp() {
appRepository.deleteAll();
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
var tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
var env = new net.siegeln.cameleer.saas.environment.EnvironmentEntity();
env.setTenantId(tenantId);
env.setSlug("default");
env.setDisplayName("Default");
env.setBootstrapToken("test-bootstrap-token");
var savedEnv = environmentRepository.save(env);
environmentId = savedEnv.getId();
}
@Test
void createApp_shouldReturn201() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "order-svc", "displayName": "Order Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "order-service.jar",
"application/java-archive", "fake-jar".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("order-svc"))
.andExpect(jsonPath("$.displayName").value("Order Service"));
}
@Test
void createApp_nonJarFile_shouldReturn400() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "order-svc", "displayName": "Order Service"}
""".getBytes());
var txt = new MockMultipartFile("file", "readme.txt",
"text/plain", "hello".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(txt)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isBadRequest());
}
@Test
void listApps_shouldReturnAll() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "billing-svc", "displayName": "Billing Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "billing-service.jar",
"application/java-archive", "fake-jar".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("billing-svc"));
}
@Test
void deleteApp_shouldReturn204() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "payment-svc", "displayName": "Payment Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "payment-service.jar",
"application/java-archive", "fake-jar".getBytes());
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated())
.andReturn();
String appId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,167 @@
package net.siegeln.cameleer.saas.app;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
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.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AppServiceTest {
@TempDir
Path tempDir;
@Mock
private AppRepository appRepository;
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
@Mock
private RuntimeConfig runtimeConfig;
private AppService appService;
@BeforeEach
void setUp() {
when(runtimeConfig.getJarStoragePath()).thenReturn(tempDir.toString());
when(runtimeConfig.getMaxJarSize()).thenReturn(209715200L);
appService = new AppService(appRepository, environmentRepository, licenseRepository, auditService, runtimeConfig);
}
@Test
void create_shouldStoreJarAndCreateApp() throws Exception {
var envId = UUID.randomUUID();
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("default");
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
var jarBytes = "fake-jar-content".getBytes();
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", jarBytes);
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(false);
when(appRepository.countByTenantId(tenantId)).thenReturn(0L);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appService.create(envId, "myapp", "My App", jarFile, actorId);
assertThat(result.getSlug()).isEqualTo("myapp");
assertThat(result.getDisplayName()).isEqualTo("My App");
assertThat(result.getEnvironmentId()).isEqualTo(envId);
assertThat(result.getJarOriginalFilename()).isEqualTo("myapp.jar");
assertThat(result.getJarSizeBytes()).isEqualTo((long) jarBytes.length);
assertThat(result.getJarChecksum()).isNotBlank();
assertThat(result.getJarStoragePath()).contains("tenants")
.contains("envs")
.contains("apps")
.endsWith("app.jar");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_CREATE);
}
@Test
void create_shouldRejectNonJarFile() {
var envId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(".jar");
}
@Test
void create_shouldRejectDuplicateSlug() {
var envId = UUID.randomUUID();
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("default");
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", "fake-jar".getBytes());
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("myapp");
}
@Test
void reuploadJar_shouldUpdateChecksumAndPath() throws Exception {
var appId = UUID.randomUUID();
var envId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var existingApp = new AppEntity();
existingApp.setId(appId);
existingApp.setEnvironmentId(envId);
existingApp.setSlug("myapp");
existingApp.setDisplayName("My App");
existingApp.setJarStoragePath("tenants/some-tenant/envs/default/apps/myapp/app.jar");
existingApp.setJarChecksum("oldchecksum");
existingApp.setJarOriginalFilename("old.jar");
existingApp.setJarSizeBytes(100L);
var newJarBytes = "new-jar-content".getBytes();
var newJarFile = new MockMultipartFile("file", "new-myapp.jar", "application/java-archive", newJarBytes);
when(appRepository.findById(appId)).thenReturn(Optional.of(existingApp));
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appService.reuploadJar(appId, newJarFile, actorId);
assertThat(result.getJarOriginalFilename()).isEqualTo("new-myapp.jar");
assertThat(result.getJarSizeBytes()).isEqualTo((long) newJarBytes.length);
assertThat(result.getJarChecksum()).isNotBlank();
assertThat(result.getJarChecksum()).isNotEqualTo("oldchecksum");
}
}

View File

@@ -0,0 +1,121 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.TestcontainersConfig;
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.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class DeploymentControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private DeploymentRepository deploymentRepository;
@Autowired
private AppRepository appRepository;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID appId;
@BeforeEach
void setUp() {
deploymentRepository.deleteAll();
appRepository.deleteAll();
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
var tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
var env = new EnvironmentEntity();
env.setTenantId(tenantId);
env.setSlug("default");
env.setDisplayName("Default");
env.setBootstrapToken("test-bootstrap-token");
var savedEnv = environmentRepository.save(env);
var app = new AppEntity();
app.setEnvironmentId(savedEnv.getId());
app.setSlug("test-app");
app.setDisplayName("Test App");
app.setJarStoragePath("tenants/test-org/envs/default/apps/test-app/app.jar");
app.setJarChecksum("abc123def456");
var savedApp = appRepository.save(app);
appId = savedApp.getId();
}
@Test
void listDeployments_shouldReturnEmpty() throws Exception {
mockMvc.perform(get("/api/apps/" + appId + "/deployments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
void getDeployment_notFound_shouldReturn404() throws Exception {
mockMvc.perform(get("/api/apps/" + appId + "/deployments/" + UUID.randomUUID())
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isNotFound());
}
@Test
void deploy_noAuth_shouldReturn401() throws Exception {
mockMvc.perform(post("/api/apps/" + appId + "/deploy"))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,179 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.app.AppService;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeploymentServiceTest {
@Mock
private DeploymentRepository deploymentRepository;
@Mock
private AppRepository appRepository;
@Mock
private AppService appService;
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private TenantRepository tenantRepository;
@Mock
private RuntimeOrchestrator runtimeOrchestrator;
@Mock
private RuntimeConfig runtimeConfig;
@Mock
private AuditService auditService;
private DeploymentService deploymentService;
private UUID appId;
private UUID envId;
private UUID tenantId;
private UUID actorId;
private AppEntity app;
private EnvironmentEntity env;
private TenantEntity tenant;
@BeforeEach
void setUp() {
deploymentService = new DeploymentService(
deploymentRepository,
appRepository,
appService,
environmentRepository,
tenantRepository,
runtimeOrchestrator,
runtimeConfig,
auditService
);
appId = UUID.randomUUID();
envId = UUID.randomUUID();
tenantId = UUID.randomUUID();
actorId = UUID.randomUUID();
env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("prod");
env.setBootstrapToken("tok-abc");
tenant = new TenantEntity();
tenant.setSlug("acme");
app = new AppEntity();
app.setId(appId);
app.setEnvironmentId(envId);
app.setSlug("myapp");
app.setDisplayName("My App");
app.setJarStoragePath("tenants/acme/envs/prod/apps/myapp/app.jar");
when(runtimeConfig.getBaseImage()).thenReturn("cameleer-runtime-base:latest");
when(runtimeConfig.getDockerNetwork()).thenReturn("cameleer");
when(runtimeConfig.getAgentHealthPort()).thenReturn(9464);
when(runtimeConfig.getHealthCheckTimeout()).thenReturn(60);
when(runtimeConfig.parseMemoryLimitBytes()).thenReturn(536870912L);
when(runtimeConfig.getContainerCpuShares()).thenReturn(512);
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(tenantRepository.findById(tenantId)).thenReturn(Optional.of(tenant));
when(deploymentRepository.findMaxVersionByAppId(appId)).thenReturn(0);
when(deploymentRepository.save(any(DeploymentEntity.class))).thenAnswer(inv -> {
var d = (DeploymentEntity) inv.getArgument(0);
if (d.getId() == null) {
d.setId(UUID.randomUUID());
}
return d;
});
when(appService.resolveJarPath(any())).thenReturn(Path.of("/data/jars/tenants/acme/envs/prod/apps/myapp/app.jar"));
when(runtimeOrchestrator.buildImage(any(BuildImageRequest.class))).thenReturn("sha256:abc123");
when(runtimeOrchestrator.startContainer(any())).thenReturn("container-id-123");
}
@Test
void deploy_shouldCreateDeploymentWithBuildingStatus() {
var result = deploymentService.deploy(appId, actorId);
assertThat(result).isNotNull();
assertThat(result.getAppId()).isEqualTo(appId);
assertThat(result.getVersion()).isEqualTo(1);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.BUILDING);
assertThat(result.getImageRef()).contains("myapp").contains("v1");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_DEPLOY);
}
@Test
void deploy_shouldRejectAppWithNoJar() {
app.setJarStoragePath(null);
assertThatThrownBy(() -> deploymentService.deploy(appId, actorId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("JAR");
}
@Test
void stop_shouldUpdateDesiredStatus() {
var deploymentId = UUID.randomUUID();
app.setCurrentDeploymentId(deploymentId);
var deployment = new DeploymentEntity();
deployment.setId(deploymentId);
deployment.setAppId(appId);
deployment.setVersion(1);
deployment.setImageRef("cameleer-runtime-prod-myapp:v1");
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setOrchestratorMetadata(Map.of("containerId", "container-id-123"));
when(deploymentRepository.findById(deploymentId)).thenReturn(Optional.of(deployment));
var result = deploymentService.stop(appId, actorId);
assertThat(result.getDesiredStatus()).isEqualTo(DesiredStatus.STOPPED);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.STOPPED);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_STOP);
}
}

View File

@@ -0,0 +1,180 @@
package net.siegeln.cameleer.saas.environment;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class EnvironmentControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID tenantId;
@BeforeEach
void setUp() {
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
}
@Test
void createEnvironment_shouldReturn201() throws Exception {
var request = new CreateEnvironmentRequest("prod", "Production");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("prod"))
.andExpect(jsonPath("$.displayName").value("Production"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
void createEnvironment_duplicateSlug_shouldReturn409() throws Exception {
var request = new CreateEnvironmentRequest("staging", "Staging");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void listEnvironments_shouldReturnAll() throws Exception {
var request = new CreateEnvironmentRequest("dev", "Development");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("dev"));
}
@Test
void updateEnvironment_shouldReturn200() throws Exception {
var createRequest = new CreateEnvironmentRequest("qa", "QA");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createRequest)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.displayName").value("QA Updated"));
}
@Test
void deleteDefaultEnvironment_shouldReturn403() throws Exception {
var request = new CreateEnvironmentRequest("default", "Default");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isForbidden());
}
@Test
void createEnvironment_noAuth_shouldReturn401() throws Exception {
var request = new CreateEnvironmentRequest("no-auth", "No Auth");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,186 @@
package net.siegeln.cameleer.saas.environment;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class EnvironmentServiceTest {
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
@Mock
private RuntimeConfig runtimeConfig;
private EnvironmentService environmentService;
@BeforeEach
void setUp() {
environmentService = new EnvironmentService(environmentRepository, licenseRepository, auditService, runtimeConfig);
}
@Test
void create_shouldCreateEnvironmentAndLogAudit() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("HIGH");
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(false);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.create(tenantId, "prod", "Production", actorId);
assertThat(result.getSlug()).isEqualTo("prod");
assertThat(result.getDisplayName()).isEqualTo("Production");
assertThat(result.getTenantId()).isEqualTo(tenantId);
assertThat(result.getBootstrapToken()).isEqualTo("test-token");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_CREATE);
}
@Test
void create_shouldRejectDuplicateSlug() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(true);
assertThatThrownBy(() -> environmentService.create(tenantId, "prod", "Production", actorId))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void create_shouldEnforceTierLimit() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("LOW");
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "staging")).thenReturn(false);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(environmentRepository.countByTenantId(tenantId)).thenReturn(1L);
assertThatThrownBy(() -> environmentService.create(tenantId, "staging", "Staging", actorId))
.isInstanceOf(IllegalStateException.class);
}
@Test
void listByTenantId_shouldReturnEnvironments() {
var tenantId = UUID.randomUUID();
var env1 = new EnvironmentEntity();
env1.setSlug("default");
var env2 = new EnvironmentEntity();
env2.setSlug("prod");
when(environmentRepository.findByTenantId(tenantId)).thenReturn(List.of(env1, env2));
var result = environmentService.listByTenantId(tenantId);
assertThat(result).hasSize(2);
assertThat(result).extracting(EnvironmentEntity::getSlug).containsExactly("default", "prod");
}
@Test
void getById_shouldReturnEnvironment() {
var id = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("prod");
when(environmentRepository.findById(id)).thenReturn(Optional.of(env));
var result = environmentService.getById(id);
assertThat(result).isPresent();
assertThat(result.get().getSlug()).isEqualTo("prod");
}
@Test
void updateDisplayName_shouldUpdateAndLogAudit() {
var environmentId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("prod");
env.setDisplayName("Old Name");
env.setTenantId(UUID.randomUUID());
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.updateDisplayName(environmentId, "New Name", actorId);
assertThat(result.getDisplayName()).isEqualTo("New Name");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_UPDATE);
}
@Test
void delete_shouldRejectDefaultEnvironment() {
var environmentId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("default");
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
assertThatThrownBy(() -> environmentService.delete(environmentId, actorId))
.isInstanceOf(IllegalStateException.class);
}
@Test
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
var tenantId = UUID.randomUUID();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("LOW");
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.createDefaultForTenant(tenantId);
assertThat(result.getSlug()).isEqualTo("default");
assertThat(result.getDisplayName()).isEqualTo("Default");
}
}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.log;
import org.junit.jupiter.api.Test;
import java.util.concurrent.ConcurrentLinkedQueue;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ContainerLogServiceTest {
@Test
void buffer_shouldAccumulateEntries() {
var buffer = new ConcurrentLinkedQueue<String>();
buffer.add("entry1");
buffer.add("entry2");
assertEquals(2, buffer.size());
assertEquals("entry1", buffer.poll());
assertEquals(1, buffer.size());
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,32 @@
package net.siegeln.cameleer.saas.runtime;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DockerRuntimeOrchestratorTest {
@Test
void runtimeConfig_parseMemoryLimitBytes_megabytes() {
assertEquals(512 * 1024 * 1024L, parseMemoryLimit("512m"));
}
@Test
void runtimeConfig_parseMemoryLimitBytes_gigabytes() {
assertEquals(1024L * 1024 * 1024, parseMemoryLimit("1g"));
}
@Test
void runtimeConfig_parseMemoryLimitBytes_bytes() {
assertEquals(536870912L, parseMemoryLimit("536870912"));
}
private long parseMemoryLimit(String limit) {
var l = limit.trim().toLowerCase();
if (l.endsWith("g")) {
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024 * 1024;
} else if (l.endsWith("m")) {
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(l);
}
}

View File

@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.BeforeEach;
@@ -32,11 +33,14 @@ class TenantServiceTest {
@Mock
private LogtoManagementClient logtoClient;
@Mock
private EnvironmentService environmentService;
private TenantService tenantService;
@BeforeEach
void setUp() {
tenantService = new TenantService(tenantRepository, auditService, logtoClient);
tenantService = new TenantService(tenantRepository, auditService, logtoClient, environmentService);
}
@Test

4
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.env.local

1
ui/.npmrc Normal file
View File

@@ -0,0 +1 @@
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/

12
ui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

27
ui/package.json Normal file
View 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
View 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
View 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,
});
}

View 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
View 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>
);
}

View 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
View 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 });
}
},
}));

View 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} />;
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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}</>;
}

View 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
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

18
ui/tsconfig.json Normal file
View 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
View 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,
},
});