feat: complete deploy demo with React UI

Backend:
- Spring Boot 3.4, Java 21, REST API for app lifecycle
- Build pipeline: JAR → Dockerfile → docker build/push → kubectl apply
- In-memory state with K8s reconciliation on startup
- Configurable via env vars

Frontend:
- React 19 + @cameleer/design-system v0.1.26
- Dashboard table with status polling (5s)
- Deploy dialog with drag-and-drop JAR upload
- Resource limit/request configuration
- Environment variable editor
- Build log viewer
- Delete with confirmation dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-03 00:20:48 +02:00
parent 5a6247abc7
commit 5ed0d80695
21 changed files with 2175 additions and 2 deletions

View File

@@ -1,3 +1,63 @@
# cameleer-deploy-demo # Cameleer Deploy Demo
Demo prototype: upload Camel JARs, build containers with agent injection, deploy to K8s with full observability Demo prototype: upload Camel JARs, build containers with automatic agent injection, deploy to K8s with full observability via cameleer3-server.
## Architecture
```
Browser (React + @cameleer/design-system)
└─ Deploy Service (Spring Boot 3, port 8082)
├─ docker build (inject cameleer3 agent)
├─ docker push (Gitea registry)
└─ kubectl apply (k3s cluster)
└─ Agent auto-registers → cameleer3-server
```
## Prerequisites
- Java 21
- Node.js 22+
- Docker (for building images)
- kubectl configured for your k3s cluster
- cameleer3-server running (for agent registration)
## Quick Start
### Backend
```bash
mvn clean package -DskipTests
java -jar target/cameleer-deploy-demo-0.1.0-SNAPSHOT.jar \
--cameleer.deploy.server-url=http://cameleer3-server.cameleer.svc:8081 \
--cameleer.deploy.bootstrap-token=YOUR_TOKEN \
--cameleer.deploy.cameleer-server-ui=http://localhost:8081
```
### Frontend
```bash
cd ui
npm install
npm run dev
```
Open http://localhost:5174
## Configuration
| Env Var | Default | Description |
|---------|---------|-------------|
| `CAMELEER_SERVER_URL` | `http://cameleer3-server.cameleer.svc:8081` | cameleer3-server URL for agent registration |
| `CAMELEER_BOOTSTRAP_TOKEN` | `changeme` | Bootstrap token for agent auth |
| `CAMELEER_REGISTRY` | `gitea.siegeln.net/cameleer/demo-apps` | Container registry prefix |
| `CAMELEER_AGENT_MAVEN_URL` | (Gitea Maven) | URL for cameleer3-agent JAR |
| `CAMELEER_DEMO_NAMESPACE` | `cameleer-demo` | K8s namespace for deployed apps |
| `CAMELEER_SERVER_UI` | `http://localhost:8081` | cameleer3-server UI URL (for links) |
## Demo Flow
1. Open the UI
2. Click "Deploy Application"
3. Upload a Camel JAR, configure resources and env vars
4. Watch the build log stream
5. Open cameleer3-server — your app appears with full observability

1
ui/.npmrc Normal file
View File

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

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cameleer Deploy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1093
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
ui/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "cameleer-deploy-demo-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@cameleer/design-system": "^0.1.26",
"@tanstack/react-query": "^5.90.21",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}

3
ui/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

43
ui/src/App.module.css Normal file
View File

@@ -0,0 +1,43 @@
.app {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-base);
color: var(--text-primary);
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.logo {
width: 28px;
height: 24px;
}
.title {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.demoBadge {
font-size: 9px;
font-weight: 700;
letter-spacing: 1px;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--amber);
color: var(--bg-base);
}
.main {
flex: 1;
overflow: auto;
padding: 1.5rem;
}

17
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Dashboard } from './Dashboard';
import styles from './App.module.css';
export function App() {
return (
<div className={styles.app}>
<header className={styles.header}>
<img src="/favicon.svg" alt="" className={styles.logo} />
<h1 className={styles.title}>Cameleer Deploy</h1>
<span className={styles.demoBadge}>DEMO</span>
</header>
<main className={styles.main}>
<Dashboard />
</main>
</div>
);
}

113
ui/src/Dashboard.module.css Normal file
View File

@@ -0,0 +1,113 @@
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.heading {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.center {
display: flex;
justify-content: center;
padding: 4rem;
}
.empty {
text-align: center;
padding: 4rem 2rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.table th {
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
}
.table td {
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.table tr:hover td {
background: var(--bg-hover);
}
.appLink {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.appLink:hover {
color: var(--accent);
}
.resources {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.age {
color: var(--text-muted);
font-size: 0.75rem;
white-space: nowrap;
}
.actions {
display: flex;
gap: 0.25rem;
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
}
.iconBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.errorMsg {
display: block;
font-size: 0.6875rem;
color: var(--error);
margin-top: 2px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

150
ui/src/Dashboard.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { Button, Badge, MonoText, ConfirmDialog, Spinner, useToast } from '@cameleer/design-system';
import { Upload, Trash2, ExternalLink, Terminal } from 'lucide-react';
import { useApps, useUndeployApp, useConfig } from './api';
import type { DeployedApp } from './api';
import { DeployDialog } from './DeployDialog';
import { LogDialog } from './LogDialog';
import styles from './Dashboard.module.css';
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
function statusColor(status: DeployedApp['status']): BadgeColor {
switch (status) {
case 'RUNNING': return 'success';
case 'BUILDING':
case 'PUSHING':
case 'DEPLOYING':
case 'PENDING': return 'warning';
case 'FAILED': return 'error';
default: return 'auto';
}
}
function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60_000) return 'just now';
if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m ago`;
if (ms < 86_400_000) return `${Math.floor(ms / 3600_000)}h ago`;
return `${Math.floor(ms / 86_400_000)}d ago`;
}
export function Dashboard() {
const { data: apps, isLoading } = useApps();
const { data: config } = useConfig();
const undeploy = useUndeployApp();
const { toast } = useToast();
const [deployOpen, setDeployOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [logTarget, setLogTarget] = useState<string | null>(null);
const cameleerUi = config?.cameleerServerUi ?? 'http://localhost:8081';
return (
<div>
<div className={styles.toolbar}>
<h2 className={styles.heading}>Deployed Applications</h2>
<Button onClick={() => setDeployOpen(true)}>
<Upload size={14} />
Deploy Application
</Button>
</div>
{isLoading ? (
<div className={styles.center}><Spinner /></div>
) : !apps || apps.length === 0 ? (
<div className={styles.empty}>
No applications deployed yet. Click "Deploy Application" to get started.
</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Image</th>
<th>Resources</th>
<th>Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apps.map((app) => (
<tr key={app.name}>
<td>
<a
href={`${cameleerUi}/exchanges/${app.name}`}
target="_blank"
rel="noopener noreferrer"
className={styles.appLink}
>
{app.name}
<ExternalLink size={12} />
</a>
</td>
<td>
<Badge label={app.status} color={statusColor(app.status)} />
{app.statusMessage && app.status === 'FAILED' && (
<span className={styles.errorMsg}>{app.statusMessage}</span>
)}
</td>
<td><MonoText size="xs">{app.imageTag || '-'}</MonoText></td>
<td className={styles.resources}>
<span>{app.resources.cpuLimit} CPU</span>
<span>{app.resources.memoryLimit} RAM</span>
</td>
<td className={styles.age}>{formatAge(app.createdAt)}</td>
<td className={styles.actions}>
<button
className={styles.iconBtn}
onClick={() => setLogTarget(app.name)}
title="Build logs"
>
<Terminal size={14} />
</button>
<button
className={styles.iconBtn}
onClick={() => setDeleteTarget(app.name)}
title="Delete"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<DeployDialog open={deployOpen} onClose={() => setDeployOpen(false)} />
<LogDialog appName={logTarget} onClose={() => setLogTarget(null)} />
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
undeploy.mutate(deleteTarget, {
onSuccess: () => {
toast({ title: 'App deleted', description: `${deleteTarget} has been undeployed`, variant: 'success' });
setDeleteTarget(null);
},
onError: (err) => {
toast({ title: 'Delete failed', description: err.message, variant: 'error' });
setDeleteTarget(null);
},
});
}
}}
title="Delete application?"
message={`This will delete the deployment "${deleteTarget}" from the cluster. This cannot be undone.`}
confirmText={deleteTarget ?? ''}
confirmLabel="Delete"
variant="danger"
loading={undeploy.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,165 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
width: 520px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.dialogHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle);
}
.dialogHeader h3 {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.closeBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
}
.closeBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.hint {
font-size: 0.6875rem;
color: var(--error);
}
.row {
display: flex;
gap: 0.5rem;
}
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius-md);
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.dropzoneHasFile {
border-style: solid;
border-color: var(--success);
background: var(--bg-hover);
}
.dropText {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted);
font-size: 0.8125rem;
}
.fileName {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.8125rem;
color: var(--success);
font-weight: 500;
}
.envRow {
display: flex;
gap: 0.375rem;
align-items: center;
}
.removeBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
flex-shrink: 0;
}
.removeBtn:hover {
color: var(--error);
}
.addEnvBtn {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
align-self: flex-start;
}
.addEnvBtn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-subtle);
}

193
ui/src/DeployDialog.tsx Normal file
View File

@@ -0,0 +1,193 @@
import { useState, useCallback, useRef } from 'react';
import { Button, Input, useToast } from '@cameleer/design-system';
import { Upload, Plus, X } from 'lucide-react';
import { useDeployApp } from './api';
import styles from './DeployDialog.module.css';
interface DeployDialogProps {
open: boolean;
onClose: () => void;
}
export function DeployDialog({ open, onClose }: DeployDialogProps) {
const { toast } = useToast();
const deploy = useDeployApp();
const fileRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [file, setFile] = useState<File | null>(null);
const [cpuRequest, setCpuRequest] = useState('250m');
const [memoryRequest, setMemoryRequest] = useState('256Mi');
const [cpuLimit, setCpuLimit] = useState('500m');
const [memoryLimit, setMemoryLimit] = useState('512Mi');
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
const reset = useCallback(() => {
setName('');
setFile(null);
setCpuRequest('250m');
setMemoryRequest('256Mi');
setCpuLimit('500m');
setMemoryLimit('512Mi');
setEnvVars([]);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const nameValid = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name);
const handleDeploy = useCallback(() => {
if (!file || !nameValid) return;
const envMap: Record<string, string> = {};
for (const { key, value } of envVars) {
if (key.trim()) envMap[key.trim()] = value;
}
deploy.mutate(
{ name, jar: file, cpuRequest, memoryRequest, cpuLimit, memoryLimit, envVars: envMap },
{
onSuccess: () => {
toast({ title: 'Deploy started', description: `${name} is building...`, variant: 'success' });
handleClose();
},
onError: (err) => {
toast({ title: 'Deploy failed', description: err.message, variant: 'error', duration: 86_400_000 });
},
},
);
}, [name, file, nameValid, cpuRequest, memoryRequest, cpuLimit, memoryLimit, envVars, deploy, toast, handleClose]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith('.jar')) setFile(f);
}, []);
if (!open) return null;
return (
<div className={styles.overlay} onClick={handleClose}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.dialogHeader}>
<h3>Deploy Application</h3>
<button className={styles.closeBtn} onClick={handleClose}><X size={16} /></button>
</div>
<div className={styles.body}>
{/* App name */}
<div className={styles.field}>
<label className={styles.label}>Application Name</label>
<Input
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder="my-camel-app"
/>
{name && !nameValid && (
<span className={styles.hint}>Lowercase letters, numbers, and hyphens only</span>
)}
</div>
{/* JAR upload */}
<div className={styles.field}>
<label className={styles.label}>Camel Application JAR</label>
<div
className={`${styles.dropzone} ${file ? styles.dropzoneHasFile : ''}`}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".jar"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
{file ? (
<span className={styles.fileName}>
<Upload size={14} /> {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</span>
) : (
<span className={styles.dropText}>
<Upload size={16} /> Drop JAR file here or click to browse
</span>
)}
</div>
</div>
{/* Resource limits */}
<div className={styles.field}>
<label className={styles.label}>Resource Requests</label>
<div className={styles.row}>
<Input value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} placeholder="CPU" />
<Input value={memoryRequest} onChange={(e) => setMemoryRequest(e.target.value)} placeholder="Memory" />
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Resource Limits</label>
<div className={styles.row}>
<Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="CPU" />
<Input value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} placeholder="Memory" />
</div>
</div>
{/* Env vars */}
<div className={styles.field}>
<label className={styles.label}>Environment Variables</label>
{envVars.map((env, i) => (
<div key={i} className={styles.envRow}>
<Input
value={env.key}
onChange={(e) => {
const next = [...envVars];
next[i] = { ...next[i], key: e.target.value };
setEnvVars(next);
}}
placeholder="KEY"
/>
<Input
value={env.value}
onChange={(e) => {
const next = [...envVars];
next[i] = { ...next[i], value: e.target.value };
setEnvVars(next);
}}
placeholder="value"
/>
<button
className={styles.removeBtn}
onClick={() => setEnvVars(envVars.filter((_, j) => j !== i))}
>
<X size={14} />
</button>
</div>
))}
<button
className={styles.addEnvBtn}
onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}
>
<Plus size={14} /> Add Variable
</button>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleClose}>Cancel</Button>
<Button
onClick={handleDeploy}
disabled={!file || !nameValid || deploy.isPending}
>
{deploy.isPending ? 'Deploying...' : 'Deploy'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.header h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
.closeBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
}
.closeBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.logArea {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.6875rem;
line-height: 1.5;
}
.logLine {
white-space: pre-wrap;
word-break: break-all;
color: var(--text-secondary);
padding: 1px 0;
}
.center {
display: flex;
justify-content: center;
padding: 2rem;
}
.empty {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}

46
ui/src/LogDialog.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { Spinner } from '@cameleer/design-system';
import { useAppLogs } from './api';
import styles from './LogDialog.module.css';
interface LogDialogProps {
appName: string | null;
onClose: () => void;
}
export function LogDialog({ appName, onClose }: LogDialogProps) {
const { data: logs, isLoading } = useAppLogs(appName);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
if (!appName) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<h3>Build Log: {appName}</h3>
<button className={styles.closeBtn} onClick={onClose}><X size={16} /></button>
</div>
<div className={styles.logArea}>
{isLoading ? (
<div className={styles.center}><Spinner /></div>
) : !logs || logs.length === 0 ? (
<div className={styles.empty}>No build logs available</div>
) : (
<>
{logs.map((line, i) => (
<div key={i} className={styles.logLine}>{line}</div>
))}
<div ref={endRef} />
</>
)}
</div>
</div>
</div>
);
}

85
ui/src/api.ts Normal file
View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export interface DeployedApp {
name: string;
imageName: string;
imageTag: string;
status: 'BUILDING' | 'PUSHING' | 'DEPLOYING' | 'RUNNING' | 'PENDING' | 'FAILED' | 'DELETED';
statusMessage: string | null;
resources: {
cpuRequest: string;
memoryRequest: string;
cpuLimit: string;
memoryLimit: string;
};
envVars: Record<string, string>;
createdAt: string;
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api/apps${path}`, init);
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (res.status === 204) return undefined as T;
return res.json();
}
export function useApps() {
return useQuery({
queryKey: ['apps'],
queryFn: () => apiFetch<DeployedApp[]>(''),
refetchInterval: 5000,
});
}
export function useAppLogs(name: string | null) {
return useQuery({
queryKey: ['apps', name, 'logs'],
queryFn: () => apiFetch<string[]>(`/${name}/logs`),
enabled: !!name,
refetchInterval: 2000,
});
}
export function useDeployApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: {
name: string;
jar: File;
cpuRequest: string;
memoryRequest: string;
cpuLimit: string;
memoryLimit: string;
envVars: Record<string, string>;
}) => {
const form = new FormData();
form.append('name', data.name);
form.append('jar', data.jar);
form.append('cpuRequest', data.cpuRequest);
form.append('memoryRequest', data.memoryRequest);
form.append('cpuLimit', data.cpuLimit);
form.append('memoryLimit', data.memoryLimit);
if (Object.keys(data.envVars).length > 0) {
form.append('envVars', JSON.stringify(data.envVars));
}
return apiFetch<DeployedApp>('', { method: 'POST', body: form });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
export function useUndeployApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => apiFetch<void>(`/${name}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
export function useConfig() {
return useQuery({
queryKey: ['config'],
queryFn: () => apiFetch<{ cameleerServerUi: string }>('/config'),
staleTime: Infinity,
});
}

22
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@cameleer/design-system';
import '@cameleer/design-system/style.css';
import { App } from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<App />
</ToastProvider>
</QueryClientProvider>
</StrictMode>,
);

6
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}

20
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

6
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

13
ui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

21
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:8082';
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api/': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
},
});