feat: strip SaaS UI to vendor management dashboard

- Delete EnvironmentsPage, EnvironmentDetailPage, AppDetailPage
- Delete EnvironmentTree and DeploymentStatusBadge components
- Simplify DashboardPage to show tenant info, license status, server link
- Remove environment/app/deployment routes from router
- Remove environment section from sidebar, keep dashboard/license/platform
- Strip API hooks to tenant/license/me only
- Remove environment/app/deployment/observability types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 00:03:01 +02:00
parent de5821dddb
commit 5938643632
10 changed files with 57 additions and 1845 deletions

View File

@@ -1,739 +0,0 @@
import React, { useRef, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router';
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 { useAuth } from '../auth/useAuth';
import {
useApp,
useDeployment,
useDeployments,
useDeploy,
useStop,
useRestart,
useDeleteApp,
useUpdateRouting,
useAgentStatus,
useObservabilityStatus,
useLogs,
useCreateApp,
} from '../api/hooks';
import { RequireScope } from '../components/RequireScope';
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
import { useScopes } from '../auth/useScopes';
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 } = useAuth();
const scopes = useScopes();
const canManageApps = scopes.has('apps:manage');
const canDeploy = scopes.has('apps:deploy');
// 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">
<RequireScope scope="apps:deploy">
<Button
variant="primary"
size="sm"
loading={deployMutation.isPending}
onClick={handleDeploy}
>
Deploy
</Button>
</RequireScope>
<RequireScope scope="apps:deploy">
<Button
variant="secondary"
size="sm"
loading={restartMutation.isPending}
onClick={handleRestart}
disabled={!app.currentDeploymentId}
>
Restart
</Button>
</RequireScope>
<RequireScope scope="apps:deploy">
<Button
variant="secondary"
size="sm"
onClick={() => setStopConfirmOpen(true)}
disabled={!app.currentDeploymentId}
>
Stop
</Button>
</RequireScope>
<RequireScope scope="apps:deploy">
<Button
variant="secondary"
size="sm"
onClick={() => {
setReuploadFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setReuploadModalOpen(true);
}}
>
Re-upload JAR
</Button>
</RequireScope>
<RequireScope scope="apps:manage">
<Button
variant="danger"
size="sm"
onClick={() => setDeleteConfirmOpen(true)}
>
Delete App
</Button>
</RequireScope>
</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>
<RequireScope scope="apps:manage">
<Button variant="secondary" size="sm" onClick={openRoutingModal}>
Edit Routing
</Button>
</RequireScope>
</div>
</Card>
</div>
)}
{/* ── Tab: Deployments ── */}
{activeTab === 'deployments' && (
<Card title="Deployment History">
{deploymentRows.length === 0 ? (
<EmptyState
title="No deployments yet"
description="Deploy your app to see history here."
/>
) : (
<DataTable<DeploymentRow>
columns={deploymentColumns}
data={deploymentRows}
pageSize={20}
rowAccent={(row) =>
row.observedStatus === 'FAILED' ? 'error' : undefined
}
flush
/>
)}
</Card>
)}
{/* ── Tab: Logs ── */}
{activeTab === 'logs' && (
<Card title="Container Logs">
<div className="space-y-3">
{/* Stream filter */}
<div className="flex gap-2">
{[
{ label: 'All', value: undefined },
{ label: 'stdout', value: 'stdout' },
{ label: 'stderr', value: 'stderr' },
].map((opt) => (
<Button
key={String(opt.value)}
variant={logStream === opt.value ? 'primary' : 'secondary'}
size="sm"
onClick={() => setLogStream(opt.value)}
>
{opt.label}
</Button>
))}
</div>
{dsLogEntries.length === 0 ? (
<EmptyState
title="No logs available"
description="Logs will appear here once the app is running."
/>
) : (
<LogViewer entries={dsLogEntries} maxHeight={500} />
)}
</div>
</Card>
)}
{/* ── Dialogs / Modals ── */}
{/* Stop confirmation */}
<ConfirmDialog
open={stopConfirmOpen}
onClose={() => setStopConfirmOpen(false)}
onConfirm={handleStop}
title="Stop App"
message={`Are you sure you want to stop "${app.displayName}"?`}
confirmText="Stop"
confirmLabel="Stop"
cancelLabel="Cancel"
variant="warning"
loading={stopMutation.isPending}
/>
{/* Delete confirmation */}
<ConfirmDialog
open={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDelete}
title="Delete App"
message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`}
confirmText="Delete"
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
loading={deleteMutation.isPending}
/>
{/* Routing modal */}
<Modal
open={routingModalOpen}
onClose={() => setRoutingModalOpen(false)}
title="Edit Routing"
size="sm"
>
<form onSubmit={handleUpdateRouting} className="space-y-4">
<FormField
label="Exposed Port"
htmlFor="exposed-port"
hint="Leave empty to remove the exposed port."
>
<Input
id="exposed-port"
type="number"
value={portInput}
onChange={(e) => setPortInput(e.target.value)}
placeholder="e.g. 8080"
min={1}
max={65535}
/>
</FormField>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setRoutingModalOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={updateRoutingMutation.isPending}
>
Save
</Button>
</div>
</form>
</Modal>
{/* Re-upload JAR modal */}
<Modal
open={reuploadModalOpen}
onClose={() => setReuploadModalOpen(false)}
title="Re-upload JAR"
size="sm"
>
<form onSubmit={handleReupload} className="space-y-4">
<FormField label="JAR File" htmlFor="reupload-jar" required>
<input
ref={fileInputRef}
id="reupload-jar"
type="file"
accept=".jar"
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
onChange={(e) => setReuploadFile(e.target.files?.[0] ?? null)}
required
/>
</FormField>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setReuploadModalOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={reuploadMutation.isPending}
disabled={!reuploadFile}
>
Upload
</Button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -1,5 +1,3 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router';
import {
Badge,
Button,
@@ -9,26 +7,7 @@ import {
Spinner,
} from '@cameleer/design-system';
import { useAuth } from '../auth/useAuth';
import { useTenant, useEnvironments, useApps } from '../api/hooks';
import { RequireScope } from '../components/RequireScope';
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;
}
import { useTenant, useLicense } from '../api/hooks';
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
switch (tier?.toLowerCase()) {
@@ -40,56 +19,30 @@ function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
}
export function DashboardPage() {
const navigate = useNavigate();
const { tenantId } = useAuth();
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
const { data: license, isLoading: licenseLoading } = useLicense(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 isLoading = tenantLoading || licenseLoading;
const kpiItems = [
{
label: 'Environments',
value: environments?.length ?? 0,
subtitle: 'isolated runtime contexts',
label: 'Tier',
value: tenant?.tier ?? '-',
subtitle: 'subscription level',
},
{
label: 'Total Apps',
value: allApps.length,
subtitle: 'across all environments',
label: 'Status',
value: tenant?.status ?? '-',
subtitle: 'tenant status',
},
{
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 },
label: 'License',
value: license ? 'Active' : 'None',
trend: license
? { label: `expires ${new Date(license.expiresAt).toLocaleDateString()}`, variant: 'success' as const }
: { label: 'no license', variant: 'warning' as const },
},
];
@@ -125,97 +78,52 @@ export function DashboardPage() {
/>
)}
</div>
<div className="flex items-center gap-2">
<RequireScope scope="apps:manage">
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/environments/new')}
>
New Environment
</Button>
</RequireScope>
<Button
variant="primary"
size="sm"
onClick={() => navigate('/dashboard')}
>
View Observability Dashboard
</Button>
</div>
<Button
variant="primary"
size="sm"
onClick={() => window.open('/server/', '_blank', 'noopener')}
>
Open Server Dashboard
</Button>
</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>
);
})}
{/* Tenant Info */}
<Card title="Tenant Information">
<div className="space-y-2 text-sm">
<div className="flex justify-between text-white/80">
<span>Slug</span>
<span className="font-mono">{tenant?.slug ?? '-'}</span>
</div>
</Card>
) : (
<EmptyState
title="No environments yet"
description="Create your first environment to get started deploying Camel applications."
action={
<RequireScope scope="apps:manage">
<Button variant="primary" onClick={() => navigate('/environments/new')}>
Create Environment
</Button>
</RequireScope>
}
/>
)}
<div className="flex justify-between text-white/80">
<span>Status</span>
<Badge
label={tenant?.status ?? 'UNKNOWN'}
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
/>
</div>
<div className="flex justify-between text-white/80">
<span>Created</span>
<span>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span>
</div>
</div>
</Card>
{/* 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>
)}
{/* Server Dashboard Link */}
<Card title="Server Management">
<p className="text-sm text-white/60 mb-3">
Environments, applications, and deployments are managed through the server dashboard.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => window.open('/server/', '_blank', 'noopener')}
>
Open Server Dashboard
</Button>
</Card>
</div>
);
}

View File

@@ -1,380 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
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 { useAuth } from '../auth/useAuth';
import {
useEnvironments,
useUpdateEnvironment,
useDeleteEnvironment,
useApps,
useCreateApp,
} from '../api/hooks';
import { RequireScope } from '../components/RequireScope';
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
import { toSlug } from '../utils/slug';
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 } = useAuth();
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 [appDisplayName, setAppDisplayName] = useState('');
const [jarFile, setJarFile] = useState<File | null>(null);
const [memoryLimit, setMemoryLimit] = useState('512m');
const [cpuShares, setCpuShares] = useState('512');
// Delete confirm
const [deleteOpen, setDeleteOpen] = useState(false);
const appSlug = toSlug(appDisplayName);
function openNewApp() {
setAppDisplayName('');
setJarFile(null);
setMemoryLimit('512m');
setCpuShares('512');
setNewAppOpen(true);
}
function closeNewApp() {
setNewAppOpen(false);
}
async function handleCreateApp(e: React.FormEvent) {
e.preventDefault();
if (!appSlug || !appDisplayName.trim()) return;
const metadata = {
slug: appSlug,
displayName: appDisplayName.trim(),
memoryLimit: memoryLimit || null,
cpuShares: cpuShares ? parseInt(cpuShares, 10) : null,
};
const formData = new FormData();
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
if (jarFile) {
formData.append('file', 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">
<RequireScope
scope="apps:manage"
fallback={
<h1 className="text-2xl font-semibold text-white">
{environment.displayName}
</h1>
}
>
<InlineEdit
value={environment.displayName}
onSave={handleRename}
placeholder="Environment name"
/>
</RequireScope>
<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">
<RequireScope scope="apps:deploy">
<Button variant="primary" size="sm" onClick={openNewApp}>
New App
</Button>
</RequireScope>
<RequireScope scope="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>
</RequireScope>
</div>
</div>
{/* Apps table */}
{tableData.length === 0 ? (
<EmptyState
title="No apps yet"
description="Deploy your first Camel application to this environment."
action={
<RequireScope scope="apps:deploy">
<Button variant="primary" onClick={openNewApp}>
New App
</Button>
</RequireScope>
}
/>
) : (
<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={appSlug ? `Deploy ${appSlug}` : 'Deploy New Application'} size="sm">
<form onSubmit={handleCreateApp} className="space-y-6">
<FormField label="Application Name" htmlFor="app-display-name" required>
<Input
id="app-display-name"
value={appDisplayName}
onChange={(e) => setAppDisplayName(e.target.value)}
placeholder="e.g. Order Router"
autoFocus
required
/>
</FormField>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<FormField label="Memory Limit" htmlFor="app-memory">
<Input
id="app-memory"
value={memoryLimit}
onChange={(e) => setMemoryLimit(e.target.value)}
placeholder="e.g. 512m, 1g"
/>
</FormField>
</div>
<div style={{ flex: 1 }}>
<FormField label="CPU Shares" htmlFor="app-cpu">
<Input
id="app-cpu"
value={cpuShares}
onChange={(e) => setCpuShares(e.target.value)}
placeholder="e.g. 512, 1024"
/>
</FormField>
</div>
</div>
{/* Drop zone — drag only, no file picker */}
<div
style={{
border: '2px dashed var(--border)',
borderRadius: 'var(--radius-md)',
padding: '2rem 1.5rem',
textAlign: 'center',
background: jarFile ? 'var(--bg-raised)' : undefined,
transition: 'border-color 0.15s',
}}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--amber)'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; }}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.style.borderColor = 'var(--border)';
const file = e.dataTransfer.files?.[0];
if (file?.name.endsWith('.jar')) setJarFile(file);
}}
>
{jarFile ? (
<>
<div style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-primary)' }}>
{jarFile.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
{(jarFile.size / 1024 / 1024).toFixed(1)} MB
</div>
<button
type="button"
style={{ fontSize: '0.75rem', color: 'var(--amber)', marginTop: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => setJarFile(null)}
>
Remove
</button>
</>
) : (
<>
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Drop your <span style={{ fontWeight: 500 }}>.jar</span> file here
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
You can also upload later from the app detail page
</div>
</>
)}
</div>
{/* Actions — cancel left, create right */}
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingTop: '1rem', borderTop: '1px solid var(--border-subtle)' }}>
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={createAppMutation.isPending}
disabled={!appSlug || !appDisplayName.trim()}
>
Create Application
</Button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDeleteEnvironment}
title="Delete Environment"
message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`}
confirmText="Delete"
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
loading={deleteMutation.isPending}
/>
</div>
);
}

View File

@@ -1,185 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router';
import {
Badge,
Button,
Card,
DataTable,
EmptyState,
FormField,
Input,
Modal,
Spinner,
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuth } from '../auth/useAuth';
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
import { RequireScope } from '../components/RequireScope';
import { toSlug } from '../utils/slug';
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 } = useAuth();
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
const createMutation = useCreateEnvironment(tenantId ?? '');
const [modalOpen, setModalOpen] = useState(false);
const [displayName, setDisplayName] = useState('');
const computedSlug = toSlug(displayName);
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() {
setDisplayName('');
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!computedSlug || !displayName.trim()) return;
try {
await createMutation.mutateAsync({ slug: computedSlug, 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>
<RequireScope scope="apps:manage">
<Button variant="primary" size="sm" onClick={openModal}>
Create Environment
</Button>
</RequireScope>
</div>
{/* Table / empty state */}
{tableData.length === 0 ? (
<EmptyState
title="No environments yet"
description="Create your first environment to start deploying Camel applications."
action={
<RequireScope scope="apps:manage">
<Button variant="primary" onClick={openModal}>
Create Environment
</Button>
</RequireScope>
}
/>
) : (
<Card>
<DataTable<TableRow>
columns={columns}
data={tableData}
onRowClick={(row) => navigate(`/environments/${row.id}`)}
flush
/>
</Card>
)}
{/* Create Environment Modal */}
<Modal open={modalOpen} onClose={closeModal} title={computedSlug ? `Create ${computedSlug}` : 'Create Environment'} size="sm">
<form onSubmit={handleCreate} className="space-y-6">
<FormField label="Environment Name" htmlFor="env-display-name" required>
<Input
id="env-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Production"
autoFocus
required
/>
</FormField>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingTop: '1rem', borderTop: '1px solid var(--border-subtle)' }}>
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={createMutation.isPending}
disabled={!computedSlug || !displayName.trim()}
>
Create Environment
</Button>
</div>
</form>
</Modal>
</div>
);
}