Compare commits
12 Commits
600985c913
...
050ff61e7a
| Author | SHA1 | Date | |
|---|---|---|---|
| 050ff61e7a | |||
|
|
e325c4d2c0 | ||
|
|
4c8c8efbe5 | ||
|
|
f6d3627abc | ||
|
|
fe786790e1 | ||
|
|
5eac48ad72 | ||
|
|
02019e9347 | ||
|
|
91a4235223 | ||
|
|
e725669aef | ||
|
|
d572926010 | ||
|
|
e33818cc74 | ||
|
|
146dbccc6e |
@@ -27,6 +27,12 @@ jobs:
|
|||||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||||
restore-keys: ${{ runner.os }}-maven-
|
restore-keys: ${{ runner.os }}-maven-
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Build and Test (unit tests only)
|
- name: Build and Test (unit tests only)
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify -B
|
mvn clean verify -B
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/package.json ui/package-lock.json ui/.npmrc ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY ui/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY .mvn/ .mvn/
|
COPY .mvn/ .mvn/
|
||||||
COPY mvnw pom.xml ./
|
COPY mvnw pom.xml ./
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
COPY --from=frontend /src/main/resources/static/ src/main/resources/static/
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|||||||
46
HOWTO.md
46
HOWTO.md
@@ -302,6 +302,52 @@ Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream`
|
|||||||
| HIGH | Unlimited | 50 | 90 days | + Debugger, Replay |
|
| HIGH | Unlimited | 50 | 90 days | + Debugger, Replay |
|
||||||
| BUSINESS | Unlimited | Unlimited | 365 days | All features |
|
| 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
|
## Development
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ services:
|
|||||||
- traefik.http.services.api.loadbalancer.server.port=8080
|
- traefik.http.services.api.loadbalancer.server.port=8080
|
||||||
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
||||||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||||||
|
- traefik.http.routers.spa.rule=PathPrefix(`/`)
|
||||||
|
- traefik.http.routers.spa.priority=1
|
||||||
|
- traefik.http.services.spa.loadbalancer.server.port=8080
|
||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class SpaController {
|
||||||
|
|
||||||
|
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
|
||||||
|
public String spa() {
|
||||||
|
return "forward:/index.html";
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ui/.gitignore
vendored
Normal file
4
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
1
ui/.npmrc
Normal file
1
ui/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
12
ui/index.html
Normal file
12
ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cameleer SaaS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1962
ui/package-lock.json
generated
Normal file
1962
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
ui/package.json
Normal file
27
ui/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "cameleer-saas-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "0.1.31",
|
||||||
|
"@tanstack/react-query": "^5.90.0",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router": "^7.13.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^6.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
ui/src/api/client.ts
Normal file
46
ui/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`API error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => apiFetch<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
apiFetch<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
patch: <T>(path: string, body: unknown) =>
|
||||||
|
apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
put: <T>(path: string, body: FormData) =>
|
||||||
|
apiFetch<T>(path, { method: 'PUT', body }),
|
||||||
|
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
185
ui/src/api/hooks.ts
Normal file
185
ui/src/api/hooks.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from './client';
|
||||||
|
import type {
|
||||||
|
TenantResponse, EnvironmentResponse, AppResponse,
|
||||||
|
DeploymentResponse, LicenseResponse, AgentStatusResponse,
|
||||||
|
ObservabilityStatusResponse, LogEntry,
|
||||||
|
} from '../types/api';
|
||||||
|
|
||||||
|
// Tenant
|
||||||
|
export function useTenant(tenantId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tenant', tenantId],
|
||||||
|
queryFn: () => api.get<TenantResponse>(`/tenants/${tenantId}`),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// License
|
||||||
|
export function useLicense(tenantId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['license', tenantId],
|
||||||
|
queryFn: () => api.get<LicenseResponse>(`/tenants/${tenantId}/license`),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environments
|
||||||
|
export function useEnvironments(tenantId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['environments', tenantId],
|
||||||
|
queryFn: () => api.get<EnvironmentResponse[]>(`/tenants/${tenantId}/environments`),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEnvironment(tenantId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { slug: string; displayName: string }) =>
|
||||||
|
api.post<EnvironmentResponse>(`/tenants/${tenantId}/environments`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEnvironment(tenantId: string, envId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { displayName: string }) =>
|
||||||
|
api.patch<EnvironmentResponse>(`/tenants/${tenantId}/environments/${envId}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEnvironment(tenantId: string, envId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
export function useApps(environmentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['apps', environmentId],
|
||||||
|
queryFn: () => api.get<AppResponse[]>(`/environments/${environmentId}/apps`),
|
||||||
|
enabled: !!environmentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApp(environmentId: string, appId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['app', appId],
|
||||||
|
queryFn: () => api.get<AppResponse>(`/environments/${environmentId}/apps/${appId}`),
|
||||||
|
enabled: !!appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApp(environmentId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (formData: FormData) =>
|
||||||
|
api.post<AppResponse>(`/environments/${environmentId}/apps`, formData),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteApp(environmentId: string, appId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRouting(environmentId: string, appId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { exposedPort: number | null }) =>
|
||||||
|
api.patch<AppResponse>(`/environments/${environmentId}/apps/${appId}/routing`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deployments
|
||||||
|
export function useDeploy(appId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/deploy`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeployments(appId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['deployments', appId],
|
||||||
|
queryFn: () => api.get<DeploymentResponse[]>(`/apps/${appId}/deployments`),
|
||||||
|
enabled: !!appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeployment(appId: string, deploymentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['deployment', deploymentId],
|
||||||
|
queryFn: () => api.get<DeploymentResponse>(`/apps/${appId}/deployments/${deploymentId}`),
|
||||||
|
enabled: !!deploymentId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.observedStatus;
|
||||||
|
return status === 'BUILDING' || status === 'STARTING' ? 3000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStop(appId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/stop`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['app'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestart(appId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/restart`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observability
|
||||||
|
export function useAgentStatus(appId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['agent-status', appId],
|
||||||
|
queryFn: () => api.get<AgentStatusResponse>(`/apps/${appId}/agent-status`),
|
||||||
|
enabled: !!appId,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObservabilityStatus(appId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['observability-status', appId],
|
||||||
|
queryFn: () => api.get<ObservabilityStatusResponse>(`/apps/${appId}/observability-status`),
|
||||||
|
enabled: !!appId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['logs', appId, params],
|
||||||
|
queryFn: () => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.since) qs.set('since', params.since);
|
||||||
|
if (params?.limit) qs.set('limit', String(params.limit));
|
||||||
|
if (params?.stream) qs.set('stream', params.stream);
|
||||||
|
const query = qs.toString();
|
||||||
|
return api.get<LogEntry[]>(`/apps/${appId}/logs${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
enabled: !!appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
49
ui/src/auth/CallbackPage.tsx
Normal file
49
ui/src/auth/CallbackPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useAuthStore } from './auth-store';
|
||||||
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
export function CallbackPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const login = useAuthStore((s) => s.login);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
if (!code) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
||||||
|
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
|
||||||
|
const redirectUri = `${window.location.origin}/callback`;
|
||||||
|
|
||||||
|
fetch(`${logtoEndpoint}/oidc/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.access_token) {
|
||||||
|
login(data.access_token, data.refresh_token || '');
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => navigate('/login'));
|
||||||
|
}, [login, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
ui/src/auth/LoginPage.tsx
Normal file
29
ui/src/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Button } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
||||||
|
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
|
||||||
|
const redirectUri = `${window.location.origin}/callback`;
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile email offline_access',
|
||||||
|
});
|
||||||
|
window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h1>Cameleer SaaS</h1>
|
||||||
|
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
Managed Apache Camel Runtime
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleLogin}>Sign in with Logto</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
ui/src/auth/ProtectedRoute.tsx
Normal file
8
ui/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Navigate } from 'react-router';
|
||||||
|
import { useAuthStore } from './auth-store';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
61
ui/src/auth/auth-store.ts
Normal file
61
ui/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
username: string | null;
|
||||||
|
roles: string[];
|
||||||
|
tenantId: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (accessToken: string, refreshToken: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
loadFromStorage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJwt(token: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
return JSON.parse(atob(base64));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
username: null,
|
||||||
|
roles: [],
|
||||||
|
tenantId: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: (accessToken: string, refreshToken: string) => {
|
||||||
|
localStorage.setItem('cameleer-access-token', accessToken);
|
||||||
|
localStorage.setItem('cameleer-refresh-token', refreshToken);
|
||||||
|
const claims = parseJwt(accessToken);
|
||||||
|
const username = (claims.sub as string) || (claims.email as string) || 'user';
|
||||||
|
const roles = (claims.roles as string[]) || [];
|
||||||
|
const tenantId = (claims.organization_id as string) || null;
|
||||||
|
localStorage.setItem('cameleer-username', username);
|
||||||
|
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('cameleer-access-token');
|
||||||
|
localStorage.removeItem('cameleer-refresh-token');
|
||||||
|
localStorage.removeItem('cameleer-username');
|
||||||
|
set({ accessToken: null, refreshToken: null, username: null, roles: [], tenantId: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadFromStorage: () => {
|
||||||
|
const accessToken = localStorage.getItem('cameleer-access-token');
|
||||||
|
const refreshToken = localStorage.getItem('cameleer-refresh-token');
|
||||||
|
const username = localStorage.getItem('cameleer-username');
|
||||||
|
if (accessToken) {
|
||||||
|
const claims = parseJwt(accessToken);
|
||||||
|
const roles = (claims.roles as string[]) || [];
|
||||||
|
const tenantId = (claims.organization_id as string) || null;
|
||||||
|
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
15
ui/src/components/DeploymentStatusBadge.tsx
Normal file
15
ui/src/components/DeploymentStatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Badge } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
// Badge color values: 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
|
||||||
|
const STATUS_COLORS: Record<string, 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'> = {
|
||||||
|
BUILDING: 'warning',
|
||||||
|
STARTING: 'warning',
|
||||||
|
RUNNING: 'running',
|
||||||
|
FAILED: 'error',
|
||||||
|
STOPPED: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeploymentStatusBadge({ status }: { status: string }) {
|
||||||
|
const color = STATUS_COLORS[status] ?? 'auto';
|
||||||
|
return <Badge label={status} color={color} />;
|
||||||
|
}
|
||||||
114
ui/src/components/EnvironmentTree.tsx
Normal file
114
ui/src/components/EnvironmentTree.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
|
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useEnvironments, useApps } from '../api/hooks';
|
||||||
|
import type { EnvironmentResponse } from '../types/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one environment entry as a SidebarTreeNode.
|
||||||
|
* This is a "render nothing, report data" component: it fetches apps for
|
||||||
|
* the given environment and invokes `onNode` with the assembled tree node
|
||||||
|
* whenever the data changes.
|
||||||
|
*
|
||||||
|
* Using a dedicated component per env is the idiomatic way to call a hook
|
||||||
|
* for each item in a dynamic list without violating Rules of Hooks.
|
||||||
|
*/
|
||||||
|
function EnvWithApps({
|
||||||
|
env,
|
||||||
|
onNode,
|
||||||
|
}: {
|
||||||
|
env: EnvironmentResponse;
|
||||||
|
onNode: (node: SidebarTreeNode) => void;
|
||||||
|
}) {
|
||||||
|
const { data: apps } = useApps(env.id);
|
||||||
|
|
||||||
|
const children: SidebarTreeNode[] = (apps ?? []).map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
label: app.displayName,
|
||||||
|
path: `/environments/${env.id}/apps/${app.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const node: SidebarTreeNode = {
|
||||||
|
id: env.id,
|
||||||
|
label: env.displayName,
|
||||||
|
path: `/environments/${env.id}`,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calling onNode during render is intentional here: we want the parent to
|
||||||
|
// collect the latest node on every render. The parent guards against
|
||||||
|
// infinite loops by doing a shallow equality check before updating state.
|
||||||
|
onNode(node);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentTree() {
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
const { data: environments } = useEnvironments(tenantId ?? '');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [starred, setStarred] = useState<Set<string>>(new Set());
|
||||||
|
const [envNodes, setEnvNodes] = useState<Map<string, SidebarTreeNode>>(new Map());
|
||||||
|
|
||||||
|
const handleToggleStar = useCallback((id: string) => {
|
||||||
|
setStarred((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNode = useCallback((node: SidebarTreeNode) => {
|
||||||
|
setEnvNodes((prev) => {
|
||||||
|
const existing = prev.get(node.id);
|
||||||
|
// Avoid infinite re-renders: only update when something meaningful changed.
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.label === node.label &&
|
||||||
|
existing.path === node.path &&
|
||||||
|
existing.children?.length === node.children?.length
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return new Map(prev).set(node.id, node);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const envs = environments ?? [];
|
||||||
|
|
||||||
|
// Build the final node list, falling back to env-only nodes until apps load.
|
||||||
|
const nodes: SidebarTreeNode[] = envs.map(
|
||||||
|
(env) =>
|
||||||
|
envNodes.get(env.id) ?? {
|
||||||
|
id: env.id,
|
||||||
|
label: env.displayName,
|
||||||
|
path: `/environments/${env.id}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Invisible data-fetchers: one per environment */}
|
||||||
|
{envs.map((env) => (
|
||||||
|
<EnvWithApps key={env.id} env={env} onNode={handleNode} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SidebarTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedPath={location.pathname}
|
||||||
|
isStarred={(id) => starred.has(id)}
|
||||||
|
onToggleStar={handleToggleStar}
|
||||||
|
onNavigate={(path) => navigate(path)}
|
||||||
|
persistKey="env-tree"
|
||||||
|
autoRevealPath={location.pathname}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
ui/src/components/Layout.tsx
Normal file
166
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Outlet, useNavigate } from 'react-router';
|
||||||
|
import {
|
||||||
|
AppShell,
|
||||||
|
Sidebar,
|
||||||
|
TopBar,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { EnvironmentTree } from './EnvironmentTree';
|
||||||
|
|
||||||
|
// Simple SVG logo mark for the sidebar header
|
||||||
|
function CameleerLogo() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15" />
|
||||||
|
<path
|
||||||
|
d="M7 14c0-2.5 2-4.5 4.5-4.5S16 11.5 16 14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="8" r="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav icon helpers
|
||||||
|
function DashboardIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnvIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 4h12M2 8h12M2 12h12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LicenseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M5 8h6M5 5h6M5 11h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObsIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M4 8h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M8 4v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path
|
||||||
|
d="M2 13c0-3 2.7-5 6-5s6 2 6 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const username = useAuthStore((s) => s.username);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
|
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const sidebar = (
|
||||||
|
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed((c) => !c)}>
|
||||||
|
<Sidebar.Header
|
||||||
|
logo={<CameleerLogo />}
|
||||||
|
title="Cameleer SaaS"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dashboard */}
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
label="Dashboard"
|
||||||
|
open={false}
|
||||||
|
onToggle={() => navigate('/')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
{/* Environments — expandable tree */}
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<EnvIcon />}
|
||||||
|
label="Environments"
|
||||||
|
open={envSectionOpen}
|
||||||
|
onToggle={() => setEnvSectionOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<EnvironmentTree />
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
{/* License */}
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<LicenseIcon />}
|
||||||
|
label="License"
|
||||||
|
open={false}
|
||||||
|
onToggle={() => navigate('/license')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Footer>
|
||||||
|
{/* Link to the observability SPA (external) */}
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<ObsIcon />}
|
||||||
|
label="View Dashboard"
|
||||||
|
onClick={() => window.open('/dashboard', '_blank', 'noopener')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User info + logout */}
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={<UserIcon />}
|
||||||
|
label={username ?? 'Account'}
|
||||||
|
onClick={logout}
|
||||||
|
/>
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell sidebar={sidebar}>
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={[]}
|
||||||
|
user={username ? { name: username } : undefined}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
ui/src/components/RequirePermission.tsx
Normal file
13
ui/src/components/RequirePermission.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permission: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequirePermission({ permission, children, fallback }: Props) {
|
||||||
|
const { has } = usePermissions();
|
||||||
|
if (!has(permission)) return fallback ? <>{fallback}</> : null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
27
ui/src/hooks/usePermissions.ts
Normal file
27
ui/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
|
||||||
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
|
||||||
|
ADMIN: ['team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
|
||||||
|
DEVELOPER: ['apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug'],
|
||||||
|
VIEWER: ['observe:read'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const roles = useAuthStore((s) => s.roles);
|
||||||
|
|
||||||
|
const permissions = new Set<string>();
|
||||||
|
for (const role of roles) {
|
||||||
|
const perms = ROLE_PERMISSIONS[role];
|
||||||
|
if (perms) perms.forEach((p) => permissions.add(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has: (permission: string) => permissions.has(permission),
|
||||||
|
canManageApps: permissions.has('apps:manage'),
|
||||||
|
canDeploy: permissions.has('apps:deploy'),
|
||||||
|
canManageTenant: permissions.has('tenant:manage'),
|
||||||
|
canViewObservability: permissions.has('observe:read'),
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
ui/src/main.tsx
Normal file
32
ui/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router';
|
||||||
|
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
|
||||||
|
import '@cameleer/design-system/style.css';
|
||||||
|
import { AppRouter } from './router';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 10_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<BreadcrumbProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRouter />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</BreadcrumbProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
737
ui/src/pages/AppDetailPage.tsx
Normal file
737
ui/src/pages/AppDetailPage.tsx
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
|
LogViewer,
|
||||||
|
Modal,
|
||||||
|
Spinner,
|
||||||
|
StatusDot,
|
||||||
|
Tabs,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import {
|
||||||
|
useApp,
|
||||||
|
useDeployment,
|
||||||
|
useDeployments,
|
||||||
|
useDeploy,
|
||||||
|
useStop,
|
||||||
|
useRestart,
|
||||||
|
useDeleteApp,
|
||||||
|
useUpdateRouting,
|
||||||
|
useAgentStatus,
|
||||||
|
useObservabilityStatus,
|
||||||
|
useLogs,
|
||||||
|
useCreateApp,
|
||||||
|
} from '../api/hooks';
|
||||||
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
|
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import type { DeploymentResponse } from '../types/api';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeploymentRow {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
observedStatus: string;
|
||||||
|
desiredStatus: string;
|
||||||
|
deployedAt: string | null;
|
||||||
|
stoppedAt: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
_raw: DeploymentResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deployment history columns ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deploymentColumns: Column<DeploymentRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'version',
|
||||||
|
header: 'Version',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<span className="font-mono text-sm text-white">v{row.version}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'observedStatus',
|
||||||
|
header: 'Status',
|
||||||
|
render: (_val, row) => <DeploymentStatusBadge status={row.observedStatus} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'desiredStatus',
|
||||||
|
header: 'Desired',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge label={row.desiredStatus} color="primary" variant="outlined" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deployedAt',
|
||||||
|
header: 'Deployed',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.deployedAt
|
||||||
|
? new Date(row.deployedAt).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stoppedAt',
|
||||||
|
header: 'Stopped',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.stoppedAt
|
||||||
|
? new Date(row.stoppedAt).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errorMessage',
|
||||||
|
header: 'Error',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.errorMessage ? (
|
||||||
|
<span className="text-xs text-red-400 font-mono">{row.errorMessage}</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Main page component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppDetailPage() {
|
||||||
|
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
const { canManageApps, canDeploy } = usePermissions();
|
||||||
|
|
||||||
|
// Active tab
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
|
// App data
|
||||||
|
const { data: app, isLoading: appLoading } = useApp(envId, appId);
|
||||||
|
|
||||||
|
// Current deployment (auto-polls while BUILDING/STARTING)
|
||||||
|
const { data: currentDeployment } = useDeployment(
|
||||||
|
appId,
|
||||||
|
app?.currentDeploymentId ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deployment history
|
||||||
|
const { data: deployments = [] } = useDeployments(appId);
|
||||||
|
|
||||||
|
// Agent and observability status
|
||||||
|
const { data: agentStatus } = useAgentStatus(appId);
|
||||||
|
const { data: obsStatus } = useObservabilityStatus(appId);
|
||||||
|
|
||||||
|
// Log stream filter
|
||||||
|
const [logStream, setLogStream] = useState<string | undefined>(undefined);
|
||||||
|
const { data: logEntries = [] } = useLogs(appId, {
|
||||||
|
limit: 500,
|
||||||
|
stream: logStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const deployMutation = useDeploy(appId);
|
||||||
|
const stopMutation = useStop(appId);
|
||||||
|
const restartMutation = useRestart(appId);
|
||||||
|
const deleteMutation = useDeleteApp(envId, appId);
|
||||||
|
const updateRoutingMutation = useUpdateRouting(envId, appId);
|
||||||
|
const reuploadMutation = useCreateApp(envId);
|
||||||
|
|
||||||
|
// Dialog / modal state
|
||||||
|
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||||
|
const [reuploadModalOpen, setReuploadModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Routing form
|
||||||
|
const [portInput, setPortInput] = useState('');
|
||||||
|
|
||||||
|
// Re-upload form
|
||||||
|
const [reuploadFile, setReuploadFile] = useState<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDeploy() {
|
||||||
|
try {
|
||||||
|
await deployMutation.mutateAsync();
|
||||||
|
toast({ title: 'Deployment triggered', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to trigger deployment', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
try {
|
||||||
|
await stopMutation.mutateAsync();
|
||||||
|
toast({ title: 'App stopped', variant: 'success' });
|
||||||
|
setStopConfirmOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to stop app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart() {
|
||||||
|
try {
|
||||||
|
await restartMutation.mutateAsync();
|
||||||
|
toast({ title: 'App restarting', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to restart app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync();
|
||||||
|
toast({ title: 'App deleted', variant: 'success' });
|
||||||
|
navigate(`/environments/${envId}`);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to delete app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateRouting(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const port = portInput.trim() === '' ? null : parseInt(portInput, 10);
|
||||||
|
if (port !== null && (isNaN(port) || port < 1 || port > 65535)) {
|
||||||
|
toast({ title: 'Invalid port number', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateRoutingMutation.mutateAsync({ exposedPort: port });
|
||||||
|
toast({ title: 'Routing updated', variant: 'success' });
|
||||||
|
setRoutingModalOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to update routing', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoutingModal() {
|
||||||
|
setPortInput(app?.exposedPort != null ? String(app.exposedPort) : '');
|
||||||
|
setRoutingModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReupload(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!reuploadFile) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('jar', reuploadFile);
|
||||||
|
if (app?.slug) formData.append('slug', app.slug);
|
||||||
|
if (app?.displayName) formData.append('displayName', app.displayName);
|
||||||
|
try {
|
||||||
|
await reuploadMutation.mutateAsync(formData);
|
||||||
|
toast({ title: 'JAR uploaded', variant: 'success' });
|
||||||
|
setReuploadModalOpen(false);
|
||||||
|
setReuploadFile(null);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to upload JAR', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Derived data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deploymentRows: DeploymentRow[] = deployments.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
version: d.version,
|
||||||
|
observedStatus: d.observedStatus,
|
||||||
|
desiredStatus: d.desiredStatus,
|
||||||
|
deployedAt: d.deployedAt,
|
||||||
|
stoppedAt: d.stoppedAt,
|
||||||
|
errorMessage: d.errorMessage,
|
||||||
|
_raw: d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map API LogEntry to design system LogEntry
|
||||||
|
const dsLogEntries: DSLogEntry[] = logEntries.map((entry) => ({
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.stream === 'stderr' ? ('error' as const) : ('info' as const),
|
||||||
|
message: entry.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Agent state → StatusDot variant
|
||||||
|
function agentDotVariant(): 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running' {
|
||||||
|
if (!agentStatus?.registered) return 'dead';
|
||||||
|
switch (agentStatus.state) {
|
||||||
|
case 'CONNECTED': return 'live';
|
||||||
|
case 'DISCONNECTED': return 'stale';
|
||||||
|
default: return 'stale';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading / not-found states ────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (appLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="App not found"
|
||||||
|
description="The requested app does not exist or you do not have access."
|
||||||
|
action={
|
||||||
|
<Button variant="secondary" onClick={() => navigate(`/environments/${envId}`)}>
|
||||||
|
Back to Environment
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Breadcrumb ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const breadcrumb = (
|
||||||
|
<nav className="flex items-center gap-1.5 text-sm text-white/50 mb-6">
|
||||||
|
<Link to="/" className="hover:text-white/80 transition-colors">Home</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/environments" className="hover:text-white/80 transition-colors">Environments</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to={`/environments/${envId}`} className="hover:text-white/80 transition-colors">
|
||||||
|
{envId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/90">{app.displayName}</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Tabs ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Deployments', value: 'deployments' },
|
||||||
|
{ label: 'Logs', value: 'logs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
{breadcrumb}
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{app.displayName}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge label={app.slug} color="primary" variant="outlined" />
|
||||||
|
{app.jarOriginalFilename && (
|
||||||
|
<span className="text-xs text-white/50 font-mono">{app.jarOriginalFilename}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
{/* ── Tab: Overview ── */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status card */}
|
||||||
|
<Card title="Current Deployment">
|
||||||
|
{!app.currentDeploymentId ? (
|
||||||
|
<div className="py-4 text-center text-white/50">No deployments yet</div>
|
||||||
|
) : !currentDeployment ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-6 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Version</div>
|
||||||
|
<span className="font-mono font-semibold text-white">
|
||||||
|
v{currentDeployment.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Status</div>
|
||||||
|
<DeploymentStatusBadge status={currentDeployment.observedStatus} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Image</div>
|
||||||
|
<span className="font-mono text-xs text-white/70">
|
||||||
|
{currentDeployment.imageRef}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{currentDeployment.deployedAt && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Deployed</div>
|
||||||
|
<span className="text-sm text-white/70">
|
||||||
|
{new Date(currentDeployment.deployedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentDeployment.errorMessage && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-xs text-white/50 mb-1">Error</div>
|
||||||
|
<span className="text-xs text-red-400 font-mono">
|
||||||
|
{currentDeployment.errorMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<Card title="Actions">
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={deployMutation.isPending}
|
||||||
|
onClick={handleDeploy}
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={restartMutation.isPending}
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={!app.currentDeploymentId}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStopConfirmOpen(true)}
|
||||||
|
disabled={!app.currentDeploymentId}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReuploadFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setReuploadModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Re-upload JAR
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Delete App
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Agent status card */}
|
||||||
|
<Card title="Agent Status">
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusDot variant={agentDotVariant()} pulse={agentStatus?.state === 'CONNECTED'} />
|
||||||
|
<span className="text-sm text-white/80">
|
||||||
|
{agentStatus?.registered ? 'Registered' : 'Not registered'}
|
||||||
|
</span>
|
||||||
|
{agentStatus?.state && (
|
||||||
|
<Badge
|
||||||
|
label={agentStatus.state}
|
||||||
|
color={agentStatus.state === 'CONNECTED' ? 'success' : 'auto'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agentStatus?.lastHeartbeat && (
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentStatus?.routeIds && agentStatus.routeIds.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Routes</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{agentStatus.routeIds.map((rid) => (
|
||||||
|
<Badge key={rid} label={rid} color="primary" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{obsStatus && (
|
||||||
|
<div className="flex flex-wrap gap-4 pt-1">
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Traces:{' '}
|
||||||
|
<span className={obsStatus.hasTraces ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Metrics:{' '}
|
||||||
|
<span className={obsStatus.hasMetrics ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasMetrics ? 'yes' : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Diagrams:{' '}
|
||||||
|
<span className={obsStatus.hasDiagrams ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasDiagrams ? 'yes' : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
View in Dashboard →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Routing card */}
|
||||||
|
<Card title="Routing">
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{app.exposedPort ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-white/50">Port</div>
|
||||||
|
<span className="font-mono text-white">{app.exposedPort}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-white/40">No port configured</span>
|
||||||
|
)}
|
||||||
|
{app.routeUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-white/50 mb-0.5">Route URL</div>
|
||||||
|
<a
|
||||||
|
href={app.routeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 font-mono transition-colors"
|
||||||
|
>
|
||||||
|
{app.routeUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button variant="secondary" size="sm" onClick={openRoutingModal}>
|
||||||
|
Edit Routing
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Deployments ── */}
|
||||||
|
{activeTab === 'deployments' && (
|
||||||
|
<Card title="Deployment History">
|
||||||
|
{deploymentRows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No deployments yet"
|
||||||
|
description="Deploy your app to see history here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable<DeploymentRow>
|
||||||
|
columns={deploymentColumns}
|
||||||
|
data={deploymentRows}
|
||||||
|
pageSize={20}
|
||||||
|
rowAccent={(row) =>
|
||||||
|
row.observedStatus === 'FAILED' ? 'error' : undefined
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Logs ── */}
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<Card title="Container Logs">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Stream filter */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'All', value: undefined },
|
||||||
|
{ label: 'stdout', value: 'stdout' },
|
||||||
|
{ label: 'stderr', value: 'stderr' },
|
||||||
|
].map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={String(opt.value)}
|
||||||
|
variant={logStream === opt.value ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogStream(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dsLogEntries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No logs available"
|
||||||
|
description="Logs will appear here once the app is running."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LogViewer entries={dsLogEntries} maxHeight={500} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Dialogs / Modals ── */}
|
||||||
|
|
||||||
|
{/* Stop confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={stopConfirmOpen}
|
||||||
|
onClose={() => setStopConfirmOpen(false)}
|
||||||
|
onConfirm={handleStop}
|
||||||
|
title="Stop App"
|
||||||
|
message={`Are you sure you want to stop "${app.displayName}"?`}
|
||||||
|
confirmText="Stop"
|
||||||
|
confirmLabel="Stop"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="warning"
|
||||||
|
loading={stopMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
onClose={() => setDeleteConfirmOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete App"
|
||||||
|
message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`}
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Routing modal */}
|
||||||
|
<Modal
|
||||||
|
open={routingModalOpen}
|
||||||
|
onClose={() => setRoutingModalOpen(false)}
|
||||||
|
title="Edit Routing"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleUpdateRouting} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Exposed Port"
|
||||||
|
htmlFor="exposed-port"
|
||||||
|
hint="Leave empty to remove the exposed port."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="exposed-port"
|
||||||
|
type="number"
|
||||||
|
value={portInput}
|
||||||
|
onChange={(e) => setPortInput(e.target.value)}
|
||||||
|
placeholder="e.g. 8080"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRoutingModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={updateRoutingMutation.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Re-upload JAR modal */}
|
||||||
|
<Modal
|
||||||
|
open={reuploadModalOpen}
|
||||||
|
onClose={() => setReuploadModalOpen(false)}
|
||||||
|
title="Re-upload JAR"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleReupload} className="space-y-4">
|
||||||
|
<FormField label="JAR File" htmlFor="reupload-jar" required>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="reupload-jar"
|
||||||
|
type="file"
|
||||||
|
accept=".jar"
|
||||||
|
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||||
|
onChange={(e) => setReuploadFile(e.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReuploadModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={reuploadMutation.isPending}
|
||||||
|
disabled={!reuploadFile}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
ui/src/pages/DashboardPage.tsx
Normal file
221
ui/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
KpiStrip,
|
||||||
|
Spinner,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useTenant, useEnvironments, useApps } from '../api/hooks';
|
||||||
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
|
import type { EnvironmentResponse, AppResponse } from '../types/api';
|
||||||
|
|
||||||
|
// Helper: fetches apps for one environment and reports data upward via effect
|
||||||
|
function EnvApps({
|
||||||
|
environment,
|
||||||
|
onData,
|
||||||
|
}: {
|
||||||
|
environment: EnvironmentResponse;
|
||||||
|
onData: (envId: string, apps: AppResponse[]) => void;
|
||||||
|
}) {
|
||||||
|
const { data } = useApps(environment.id);
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
onData(environment.id, data);
|
||||||
|
}
|
||||||
|
}, [data, environment.id, onData]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||||
|
switch (tier?.toLowerCase()) {
|
||||||
|
case 'enterprise': return 'success';
|
||||||
|
case 'pro': return 'primary';
|
||||||
|
case 'starter': return 'warning';
|
||||||
|
default: return 'primary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
|
||||||
|
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
|
||||||
|
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||||
|
|
||||||
|
// Collect apps per environment using a ref-like approach via state + callback
|
||||||
|
const [appsByEnv, setAppsByEnv] = useState<Record<string, AppResponse[]>>({});
|
||||||
|
|
||||||
|
const handleAppsData = useCallback((envId: string, apps: AppResponse[]) => {
|
||||||
|
setAppsByEnv((prev) => {
|
||||||
|
if (prev[envId] === apps) return prev; // stable reference, no update
|
||||||
|
return { ...prev, [envId]: apps };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allApps = Object.values(appsByEnv).flat();
|
||||||
|
const runningApps = allApps.filter((a) => a.currentDeploymentId !== null);
|
||||||
|
// "Failed" is apps that have a previous deployment but no current (stopped) — approximate heuristic
|
||||||
|
const failedApps = allApps.filter(
|
||||||
|
(a) => a.currentDeploymentId === null && a.previousDeploymentId !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = tenantLoading || envsLoading;
|
||||||
|
|
||||||
|
const kpiItems = [
|
||||||
|
{
|
||||||
|
label: 'Environments',
|
||||||
|
value: environments?.length ?? 0,
|
||||||
|
subtitle: 'isolated runtime contexts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Apps',
|
||||||
|
value: allApps.length,
|
||||||
|
subtitle: 'across all environments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Running',
|
||||||
|
value: runningApps.length,
|
||||||
|
trend: {
|
||||||
|
label: 'active deployments',
|
||||||
|
variant: 'success' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stopped',
|
||||||
|
value: failedApps.length,
|
||||||
|
trend: failedApps.length > 0
|
||||||
|
? { label: 'need attention', variant: 'warning' as const }
|
||||||
|
: { label: 'none', variant: 'muted' as const },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No tenant associated"
|
||||||
|
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Tenant Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">
|
||||||
|
{tenant?.name ?? tenantId}
|
||||||
|
</h1>
|
||||||
|
{tenant?.tier && (
|
||||||
|
<Badge
|
||||||
|
label={tenant.tier.toUpperCase()}
|
||||||
|
color={tierColor(tenant.tier)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/environments/new')}
|
||||||
|
>
|
||||||
|
New Environment
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
View Observability Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Strip */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Environments overview */}
|
||||||
|
{environments && environments.length > 0 ? (
|
||||||
|
<Card title="Environments">
|
||||||
|
{/* Render hidden data-fetchers for each environment */}
|
||||||
|
{environments.map((env) => (
|
||||||
|
<EnvApps key={env.id} environment={env} onData={handleAppsData} />
|
||||||
|
))}
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{environments.map((env) => {
|
||||||
|
const envApps = appsByEnv[env.id] ?? [];
|
||||||
|
const envRunning = envApps.filter((a) => a.currentDeploymentId !== null).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={env.id}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0 cursor-pointer hover:bg-white/5 px-2 rounded"
|
||||||
|
onClick={() => navigate(`/environments/${env.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{env.displayName}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
label={env.slug}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-white/60">
|
||||||
|
<span>{envApps.length} apps</span>
|
||||||
|
<span className="text-green-400">{envRunning} running</span>
|
||||||
|
<Badge
|
||||||
|
label={env.status}
|
||||||
|
color={env.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No environments yet"
|
||||||
|
description="Create your first environment to get started deploying Camel applications."
|
||||||
|
action={
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button variant="primary" onClick={() => navigate('/environments/new')}>
|
||||||
|
Create Environment
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent deployments placeholder */}
|
||||||
|
<Card title="Recent Deployments">
|
||||||
|
{allApps.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No deployments yet"
|
||||||
|
description="Deploy your first app to see deployment history here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Select an app from an environment to view its deployment history.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
318
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
318
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
FormField,
|
||||||
|
InlineEdit,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Spinner,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import {
|
||||||
|
useEnvironments,
|
||||||
|
useUpdateEnvironment,
|
||||||
|
useDeleteEnvironment,
|
||||||
|
useApps,
|
||||||
|
useCreateApp,
|
||||||
|
} from '../api/hooks';
|
||||||
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
|
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||||
|
import type { AppResponse } from '../types/api';
|
||||||
|
|
||||||
|
interface AppTableRow {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
slug: string;
|
||||||
|
deploymentStatus: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_raw: AppResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appColumns: Column<AppTableRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Name',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<span className="font-medium text-white">{row.displayName}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slug',
|
||||||
|
header: 'Slug',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deploymentStatus',
|
||||||
|
header: 'Status',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row._raw.currentDeploymentId ? (
|
||||||
|
<DeploymentStatusBadge status={row.deploymentStatus} />
|
||||||
|
) : (
|
||||||
|
<Badge label="Not deployed" color="auto" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updatedAt',
|
||||||
|
header: 'Last Updated',
|
||||||
|
render: (_val, row) =>
|
||||||
|
new Date(row.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EnvironmentDetailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { envId } = useParams<{ envId: string }>();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
|
||||||
|
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||||
|
const environment = environments?.find((e) => e.id === envId);
|
||||||
|
|
||||||
|
const { data: apps, isLoading: appsLoading } = useApps(envId ?? '');
|
||||||
|
|
||||||
|
const updateMutation = useUpdateEnvironment(tenantId ?? '', envId ?? '');
|
||||||
|
const deleteMutation = useDeleteEnvironment(tenantId ?? '', envId ?? '');
|
||||||
|
const createAppMutation = useCreateApp(envId ?? '');
|
||||||
|
|
||||||
|
// New app modal
|
||||||
|
const [newAppOpen, setNewAppOpen] = useState(false);
|
||||||
|
const [appSlug, setAppSlug] = useState('');
|
||||||
|
const [appDisplayName, setAppDisplayName] = useState('');
|
||||||
|
const [jarFile, setJarFile] = useState<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Delete confirm
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
function openNewApp() {
|
||||||
|
setAppSlug('');
|
||||||
|
setAppDisplayName('');
|
||||||
|
setJarFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setNewAppOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewApp() {
|
||||||
|
setNewAppOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateApp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!appSlug.trim() || !appDisplayName.trim()) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('slug', appSlug.trim());
|
||||||
|
formData.append('displayName', appDisplayName.trim());
|
||||||
|
if (jarFile) {
|
||||||
|
formData.append('jar', jarFile);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createAppMutation.mutateAsync(formData);
|
||||||
|
toast({ title: 'App created', variant: 'success' });
|
||||||
|
closeNewApp();
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to create app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEnvironment() {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync();
|
||||||
|
toast({ title: 'Environment deleted', variant: 'success' });
|
||||||
|
navigate('/environments');
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to delete environment', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename(value: string) {
|
||||||
|
if (!value.trim() || value === environment?.displayName) return;
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({ displayName: value.trim() });
|
||||||
|
toast({ title: 'Environment renamed', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to rename environment', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = envsLoading || appsLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environment) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="Environment not found"
|
||||||
|
description="The requested environment does not exist or you do not have access."
|
||||||
|
action={
|
||||||
|
<Button variant="secondary" onClick={() => navigate('/environments')}>
|
||||||
|
Back to Environments
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: AppTableRow[] = (apps ?? []).map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
displayName: app.displayName,
|
||||||
|
slug: app.slug,
|
||||||
|
deploymentStatus: app.currentDeploymentId ? 'RUNNING' : 'STOPPED',
|
||||||
|
updatedAt: app.updatedAt,
|
||||||
|
_raw: app,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasApps = (apps?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RequirePermission
|
||||||
|
permission="apps:manage"
|
||||||
|
fallback={
|
||||||
|
<h1 className="text-2xl font-semibold text-white">
|
||||||
|
{environment.displayName}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineEdit
|
||||||
|
value={environment.displayName}
|
||||||
|
onSave={handleRename}
|
||||||
|
placeholder="Environment name"
|
||||||
|
/>
|
||||||
|
</RequirePermission>
|
||||||
|
<Badge label={environment.slug} color="primary" variant="outlined" />
|
||||||
|
<Badge
|
||||||
|
label={environment.status}
|
||||||
|
color={environment.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button variant="primary" size="sm" onClick={openNewApp}>
|
||||||
|
New App
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={hasApps}
|
||||||
|
title={hasApps ? 'Remove all apps before deleting this environment' : undefined}
|
||||||
|
>
|
||||||
|
Delete Environment
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps table */}
|
||||||
|
{tableData.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No apps yet"
|
||||||
|
description="Deploy your first Camel application to this environment."
|
||||||
|
action={
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button variant="primary" onClick={openNewApp}>
|
||||||
|
New App
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card title="Apps">
|
||||||
|
<DataTable<AppTableRow>
|
||||||
|
columns={appColumns}
|
||||||
|
data={tableData}
|
||||||
|
onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)}
|
||||||
|
flush
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New App Modal */}
|
||||||
|
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
|
||||||
|
<form onSubmit={handleCreateApp} className="space-y-4">
|
||||||
|
<FormField label="Slug" htmlFor="app-slug" required>
|
||||||
|
<Input
|
||||||
|
id="app-slug"
|
||||||
|
value={appSlug}
|
||||||
|
onChange={(e) => setAppSlug(e.target.value)}
|
||||||
|
placeholder="e.g. order-router"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Display Name" htmlFor="app-display-name" required>
|
||||||
|
<Input
|
||||||
|
id="app-display-name"
|
||||||
|
value={appDisplayName}
|
||||||
|
onChange={(e) => setAppDisplayName(e.target.value)}
|
||||||
|
placeholder="e.g. Order Router"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="JAR File" htmlFor="app-jar">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="app-jar"
|
||||||
|
type="file"
|
||||||
|
accept=".jar"
|
||||||
|
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||||
|
onChange={(e) => setJarFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={createAppMutation.isPending}
|
||||||
|
disabled={!appSlug.trim() || !appDisplayName.trim()}
|
||||||
|
>
|
||||||
|
Create App
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDeleteEnvironment}
|
||||||
|
title="Delete Environment"
|
||||||
|
message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`}
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
ui/src/pages/EnvironmentsPage.tsx
Normal file
193
ui/src/pages/EnvironmentsPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Spinner,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||||
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
|
import type { EnvironmentResponse } from '../types/api';
|
||||||
|
|
||||||
|
interface TableRow {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
_raw: EnvironmentResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<TableRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Name',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<span className="font-medium text-white">{row.displayName}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slug',
|
||||||
|
header: 'Slug',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge
|
||||||
|
label={row.status}
|
||||||
|
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: 'Created',
|
||||||
|
render: (_val, row) =>
|
||||||
|
new Date(row.createdAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EnvironmentsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
|
||||||
|
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
||||||
|
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
|
||||||
|
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
||||||
|
id: env.id,
|
||||||
|
displayName: env.displayName,
|
||||||
|
slug: env.slug,
|
||||||
|
status: env.status,
|
||||||
|
createdAt: env.createdAt,
|
||||||
|
_raw: env,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
setSlug('');
|
||||||
|
setDisplayName('');
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!slug.trim() || !displayName.trim()) return;
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
||||||
|
toast({ title: 'Environment created', variant: 'success' });
|
||||||
|
closeModal();
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to create environment', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">Environments</h1>
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button variant="primary" size="sm" onClick={openModal}>
|
||||||
|
Create Environment
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table / empty state */}
|
||||||
|
{tableData.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No environments yet"
|
||||||
|
description="Create your first environment to start deploying Camel applications."
|
||||||
|
action={
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button variant="primary" onClick={openModal}>
|
||||||
|
Create Environment
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<DataTable<TableRow>
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
onRowClick={(row) => navigate(`/environments/${row.id}`)}
|
||||||
|
flush
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Environment Modal */}
|
||||||
|
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<FormField label="Slug" htmlFor="env-slug" required>
|
||||||
|
<Input
|
||||||
|
id="env-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="e.g. production"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Display Name" htmlFor="env-display-name" required>
|
||||||
|
<Input
|
||||||
|
id="env-display-name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="e.g. Production"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
disabled={!slug.trim() || !displayName.trim()}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
ui/src/pages/LicensePage.tsx
Normal file
184
ui/src/pages/LicensePage.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
Spinner,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import { useLicense } from '../api/hooks';
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
topology: 'Topology',
|
||||||
|
lineage: 'Lineage',
|
||||||
|
correlation: 'Correlation',
|
||||||
|
debugger: 'Debugger',
|
||||||
|
replay: 'Replay',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT_LABELS: Record<string, string> = {
|
||||||
|
maxAgents: 'Max Agents',
|
||||||
|
retentionDays: 'Retention Days',
|
||||||
|
maxEnvironments: 'Max Environments',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||||
|
switch (tier?.toUpperCase()) {
|
||||||
|
case 'BUSINESS': return 'success';
|
||||||
|
case 'HIGH': return 'primary';
|
||||||
|
case 'MID': return 'warning';
|
||||||
|
case 'LOW': return 'error';
|
||||||
|
default: return 'primary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysRemaining(expiresAt: string): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const exp = new Date(expiresAt).getTime();
|
||||||
|
return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicensePage() {
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
||||||
|
const [tokenExpanded, setTokenExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No tenant associated"
|
||||||
|
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !license) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="License unavailable"
|
||||||
|
description="Unable to load license information. Please try again later."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
const days = daysRemaining(license.expiresAt);
|
||||||
|
const isExpiringSoon = days <= 30;
|
||||||
|
const isExpired = days === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">License</h1>
|
||||||
|
<Badge
|
||||||
|
label={license.tier.toUpperCase()}
|
||||||
|
color={tierColor(license.tier)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry info */}
|
||||||
|
<Card title="Validity">
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Issued</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{new Date(license.issuedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Expires</span>
|
||||||
|
<span className="text-white">{expDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60">Days remaining</span>
|
||||||
|
<Badge
|
||||||
|
label={isExpired ? 'Expired' : `${days} days`}
|
||||||
|
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Feature matrix */}
|
||||||
|
<Card title="Features">
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{Object.entries(FEATURE_LABELS).map(([key, label]) => {
|
||||||
|
const enabled = license.features[key] ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white">{label}</span>
|
||||||
|
<Badge
|
||||||
|
label={enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
color={enabled ? 'success' : 'error'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Limits */}
|
||||||
|
<Card title="Limits">
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{Object.entries(LIMIT_LABELS).map(([key, label]) => {
|
||||||
|
const value = license.limits[key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white/60">{label}</span>
|
||||||
|
<span className="text-sm font-mono text-white">
|
||||||
|
{value !== undefined ? value : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* License token */}
|
||||||
|
<Card title="License Token">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Use this token when registering Cameleer agents with your tenant.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
|
||||||
|
onClick={() => setTokenExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||||
|
</button>
|
||||||
|
{tokenExpanded && (
|
||||||
|
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
|
||||||
|
<code className="text-xs font-mono text-white/80 break-all">
|
||||||
|
{license.token}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
ui/src/router.tsx
Normal file
39
ui/src/router.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Routes, Route } from 'react-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from './auth/auth-store';
|
||||||
|
import { LoginPage } from './auth/LoginPage';
|
||||||
|
import { CallbackPage } from './auth/CallbackPage';
|
||||||
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
|
import { EnvironmentsPage } from './pages/EnvironmentsPage';
|
||||||
|
import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
|
||||||
|
import { AppDetailPage } from './pages/AppDetailPage';
|
||||||
|
import { LicensePage } from './pages/LicensePage';
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage();
|
||||||
|
}, [loadFromStorage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="environments" element={<EnvironmentsPage />} />
|
||||||
|
<Route path="environments/:envId" element={<EnvironmentDetailPage />} />
|
||||||
|
<Route path="environments/:envId/apps/:appId" element={<AppDetailPage />} />
|
||||||
|
<Route path="license" element={<LicensePage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
ui/src/types/api.ts
Normal file
85
ui/src/types/api.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export interface TenantResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentResponse {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppResponse {
|
||||||
|
id: string;
|
||||||
|
environmentId: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
jarOriginalFilename: string | null;
|
||||||
|
jarSizeBytes: number | null;
|
||||||
|
jarChecksum: string | null;
|
||||||
|
exposedPort: number | null;
|
||||||
|
routeUrl: string | null;
|
||||||
|
currentDeploymentId: string | null;
|
||||||
|
previousDeploymentId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeploymentResponse {
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
version: number;
|
||||||
|
imageRef: string;
|
||||||
|
desiredStatus: string;
|
||||||
|
observedStatus: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
orchestratorMetadata: Record<string, unknown>;
|
||||||
|
deployedAt: string | null;
|
||||||
|
stoppedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseResponse {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
tier: string;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStatusResponse {
|
||||||
|
registered: boolean;
|
||||||
|
state: string;
|
||||||
|
lastHeartbeat: string | null;
|
||||||
|
routeIds: string[];
|
||||||
|
applicationId: string;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservabilityStatusResponse {
|
||||||
|
hasTraces: boolean;
|
||||||
|
hasMetrics: boolean;
|
||||||
|
hasDiagrams: boolean;
|
||||||
|
lastTraceAt: string | null;
|
||||||
|
traceCount24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
appId: string;
|
||||||
|
deploymentId: string;
|
||||||
|
timestamp: string;
|
||||||
|
stream: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
ui/tsconfig.json
Normal file
18
ui/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
19
ui/vite.config.ts
Normal file
19
ui/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user