From 5938643632b6b118e76ca53f0dfd3e7a895a2346 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:03:01 +0200 Subject: [PATCH] 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) --- ui/src/api/hooks.ts | 166 +---- ui/src/components/DeploymentStatusBadge.tsx | 15 - ui/src/components/EnvironmentTree.tsx | 114 --- ui/src/components/Layout.tsx | 34 +- ui/src/pages/AppDetailPage.tsx | 739 -------------------- ui/src/pages/DashboardPage.tsx | 196 ++---- ui/src/pages/EnvironmentDetailPage.tsx | 380 ---------- ui/src/pages/EnvironmentsPage.tsx | 185 ----- ui/src/router.tsx | 6 - ui/src/types/api.ts | 67 -- 10 files changed, 57 insertions(+), 1845 deletions(-) delete mode 100644 ui/src/components/DeploymentStatusBadge.tsx delete mode 100644 ui/src/components/EnvironmentTree.tsx delete mode 100644 ui/src/pages/AppDetailPage.tsx delete mode 100644 ui/src/pages/EnvironmentDetailPage.tsx delete mode 100644 ui/src/pages/EnvironmentsPage.tsx diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index ceb7bda..a7d2512 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -1,9 +1,7 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { api } from './client'; import type { - TenantResponse, EnvironmentResponse, AppResponse, - DeploymentResponse, LicenseResponse, AgentStatusResponse, - ObservabilityStatusResponse, LogEntry, MeResponse, + TenantResponse, LicenseResponse, MeResponse, } from '../types/api'; // Tenant @@ -24,166 +22,6 @@ export function useLicense(tenantId: string) { }); } -// Environments -export function useEnvironments(tenantId: string) { - return useQuery({ - queryKey: ['environments', tenantId], - queryFn: () => api.get(`/tenants/${tenantId}/environments`), - enabled: !!tenantId, - }); -} - -export function useCreateEnvironment(tenantId: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (data: { slug: string; displayName: string }) => - api.post(`/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(`/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(`/environments/${environmentId}/apps`), - enabled: !!environmentId, - }); -} - -export function useApp(environmentId: string, appId: string) { - return useQuery({ - queryKey: ['app', appId], - queryFn: () => api.get(`/environments/${environmentId}/apps/${appId}`), - enabled: !!appId, - }); -} - -export function useCreateApp(environmentId: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (formData: FormData) => - api.post(`/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(`/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(`/apps/${appId}/deploy`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), - }); -} - -export function useDeployments(appId: string) { - return useQuery({ - queryKey: ['deployments', appId], - queryFn: () => api.get(`/apps/${appId}/deployments`), - enabled: !!appId, - }); -} - -export function useDeployment(appId: string, deploymentId: string) { - return useQuery({ - queryKey: ['deployment', deploymentId], - queryFn: () => api.get(`/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(`/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(`/apps/${appId}/restart`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), - }); -} - -// Observability -export function useAgentStatus(appId: string) { - return useQuery({ - queryKey: ['agent-status', appId], - queryFn: () => api.get(`/apps/${appId}/agent-status`), - enabled: !!appId, - refetchInterval: 15_000, - }); -} - -export function useObservabilityStatus(appId: string) { - return useQuery({ - queryKey: ['observability-status', appId], - queryFn: () => api.get(`/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(`/apps/${appId}/logs${query ? `?${query}` : ''}`); - }, - enabled: !!appId, - }); -} - // Platform export function useMe() { return useQuery({ diff --git a/ui/src/components/DeploymentStatusBadge.tsx b/ui/src/components/DeploymentStatusBadge.tsx deleted file mode 100644 index 13965cf..0000000 --- a/ui/src/components/DeploymentStatusBadge.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Badge } from '@cameleer/design-system'; - -// Badge color values: 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' -const STATUS_COLORS: Record = { - BUILDING: 'warning', - STARTING: 'warning', - RUNNING: 'running', - FAILED: 'error', - STOPPED: 'auto', -}; - -export function DeploymentStatusBadge({ status }: { status: string }) { - const color = STATUS_COLORS[status] ?? 'auto'; - return ; -} diff --git a/ui/src/components/EnvironmentTree.tsx b/ui/src/components/EnvironmentTree.tsx deleted file mode 100644 index fef1ac0..0000000 --- a/ui/src/components/EnvironmentTree.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useNavigate, useLocation } from 'react-router'; -import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system'; -import { useAuth } from '../auth/useAuth'; -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 } = useAuth(); - const { data: environments } = useEnvironments(tenantId ?? ''); - const navigate = useNavigate(); - const location = useLocation(); - - const [starred, setStarred] = useState>(new Set()); - const [envNodes, setEnvNodes] = useState>(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) => ( - - ))} - - starred.has(id)} - onToggleStar={handleToggleStar} - onNavigate={(path) => navigate(path)} - persistKey="env-tree" - autoRevealPath={location.pathname} - /> - - ); -} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 2ee7e36..73b4d05 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Outlet, useNavigate } from 'react-router'; import { AppShell, @@ -8,7 +7,6 @@ import { import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; import { useOrgStore } from '../auth/useOrganization'; -import { EnvironmentTree } from './EnvironmentTree'; import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg'; function CameleerLogo() { @@ -35,19 +33,6 @@ function DashboardIcon() { ); } -function EnvIcon() { - return ( - - ); -} - function LicenseIcon() { return ( v{row.version} - ), - }, - { - key: 'observedStatus', - header: 'Status', - render: (_val, row) => , - }, - { - key: 'desiredStatus', - header: 'Desired', - render: (_val, row) => ( - - ), - }, - { - 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 ? ( - {row.errorMessage} - ) : ( - '—' - ), - }, -]; - -// ─── 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(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(null); - const fileInputRef = useRef(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 ( -
- -
- ); - } - - if (!app) { - return ( - navigate(`/environments/${envId}`)}> - Back to Environment - - } - /> - ); - } - - // ─── Breadcrumb ───────────────────────────────────────────────────────────── - - const breadcrumb = ( - - ); - - // ─── Tabs ─────────────────────────────────────────────────────────────────── - - const tabs = [ - { label: 'Overview', value: 'overview' }, - { label: 'Deployments', value: 'deployments' }, - { label: 'Logs', value: 'logs' }, - ]; - - // ─── Render ────────────────────────────────────────────────────────────────── - - return ( -
- {/* Breadcrumb */} - {breadcrumb} - - {/* Page header */} -
-
-

{app.displayName}

-
- - {app.jarOriginalFilename && ( - {app.jarOriginalFilename} - )} -
-
-
- - {/* Tab navigation */} - - - {/* ── Tab: Overview ── */} - {activeTab === 'overview' && ( -
- {/* Status card */} - - {!app.currentDeploymentId ? ( -
No deployments yet
- ) : !currentDeployment ? ( -
- -
- ) : ( -
-
-
Version
- - v{currentDeployment.version} - -
-
-
Status
- -
-
-
Image
- - {currentDeployment.imageRef} - -
- {currentDeployment.deployedAt && ( -
-
Deployed
- - {new Date(currentDeployment.deployedAt).toLocaleString()} - -
- )} - {currentDeployment.errorMessage && ( -
-
Error
- - {currentDeployment.errorMessage} - -
- )} -
- )} -
- - {/* Action bar */} - -
- - - - - - - - - - - - - - - - - - - -
-
- - {/* Agent status card */} - -
-
- - - {agentStatus?.registered ? 'Registered' : 'Not registered'} - - {agentStatus?.state && ( - - )} -
- - {agentStatus?.lastHeartbeat && ( -
- Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()} -
- )} - - {agentStatus?.routeIds && agentStatus.routeIds.length > 0 && ( -
-
Routes
-
- {agentStatus.routeIds.map((rid) => ( - - ))} -
-
- )} - - {obsStatus && ( -
- - Traces:{' '} - - {obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'} - - - - Metrics:{' '} - - {obsStatus.hasMetrics ? 'yes' : 'none'} - - - - Diagrams:{' '} - - {obsStatus.hasDiagrams ? 'yes' : 'none'} - - -
- )} - -
- - View in Dashboard → - -
-
-
- - {/* Routing card */} - -
-
- {app.exposedPort ? ( - <> -
Port
- {app.exposedPort} - - ) : ( - No port configured - )} - {app.routeUrl && ( -
-
Route URL
- - {app.routeUrl} - -
- )} -
- - - -
-
-
- )} - - {/* ── Tab: Deployments ── */} - {activeTab === 'deployments' && ( - - {deploymentRows.length === 0 ? ( - - ) : ( - - columns={deploymentColumns} - data={deploymentRows} - pageSize={20} - rowAccent={(row) => - row.observedStatus === 'FAILED' ? 'error' : undefined - } - flush - /> - )} - - )} - - {/* ── Tab: Logs ── */} - {activeTab === 'logs' && ( - -
- {/* Stream filter */} -
- {[ - { label: 'All', value: undefined }, - { label: 'stdout', value: 'stdout' }, - { label: 'stderr', value: 'stderr' }, - ].map((opt) => ( - - ))} -
- - {dsLogEntries.length === 0 ? ( - - ) : ( - - )} -
-
- )} - - {/* ── Dialogs / Modals ── */} - - {/* Stop confirmation */} - 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 */} - 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 */} - setRoutingModalOpen(false)} - title="Edit Routing" - size="sm" - > -
- - setPortInput(e.target.value)} - placeholder="e.g. 8080" - min={1} - max={65535} - /> - -
- - -
-
-
- - {/* Re-upload JAR modal */} - setReuploadModalOpen(false)} - title="Re-upload JAR" - size="sm" - > -
- - setReuploadFile(e.target.files?.[0] ?? null)} - required - /> - -
- - -
-
-
-
- ); -} diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index 85382e9..940dd98 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -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>({}); - - 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() { /> )} -
- - - - -
+ {/* KPI Strip */} - {/* Environments overview */} - {environments && environments.length > 0 ? ( - - {/* Render hidden data-fetchers for each environment */} - {environments.map((env) => ( - - ))} -
- {environments.map((env) => { - const envApps = appsByEnv[env.id] ?? []; - const envRunning = envApps.filter((a) => a.currentDeploymentId !== null).length; - return ( -
navigate(`/environments/${env.id}`)} - > -
- - {env.displayName} - - -
-
- {envApps.length} apps - {envRunning} running - -
-
- ); - })} + {/* Tenant Info */} + +
+
+ Slug + {tenant?.slug ?? '-'}
- - ) : ( - - - - } - /> - )} +
+ Status + +
+
+ Created + {tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'} +
+
+
- {/* Recent deployments placeholder */} - - {allApps.length === 0 ? ( - - ) : ( -

- Select an app from an environment to view its deployment history. -

- )} + {/* Server Dashboard Link */} + +

+ Environments, applications, and deployments are managed through the server dashboard. +

+
); } - diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx deleted file mode 100644 index 1316e5b..0000000 --- a/ui/src/pages/EnvironmentDetailPage.tsx +++ /dev/null @@ -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[] = [ - { - key: 'displayName', - header: 'Name', - render: (_val, row) => ( - {row.displayName} - ), - }, - { - key: 'slug', - header: 'Slug', - render: (_val, row) => ( - - ), - }, - { - key: 'deploymentStatus', - header: 'Status', - render: (_val, row) => - row._raw.currentDeploymentId ? ( - - ) : ( - - ), - }, - { - 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(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 ( -
- -
- ); - } - - if (!environment) { - return ( - navigate('/environments')}> - Back to Environments - - } - /> - ); - } - - 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 ( -
- {/* Header */} -
-
- - {environment.displayName} - - } - > - - - - -
-
- - - - - - -
-
- - {/* Apps table */} - {tableData.length === 0 ? ( - - - - } - /> - ) : ( - - - columns={appColumns} - data={tableData} - onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)} - flush - /> - - )} - - {/* New App Modal */} - -
- - setAppDisplayName(e.target.value)} - placeholder="e.g. Order Router" - autoFocus - required - /> - - -
-
- - setMemoryLimit(e.target.value)} - placeholder="e.g. 512m, 1g" - /> - -
-
- - setCpuShares(e.target.value)} - placeholder="e.g. 512, 1024" - /> - -
-
- - {/* Drop zone — drag only, no file picker */} -
{ 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 ? ( - <> -
- {jarFile.name} -
-
- {(jarFile.size / 1024 / 1024).toFixed(1)} MB -
- - - ) : ( - <> -
- Drop your .jar file here -
-
- You can also upload later from the app detail page -
- - )} -
- - {/* Actions — cancel left, create right */} -
- - -
-
-
- - {/* Delete Confirmation */} - 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} - /> -
- ); -} diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx deleted file mode 100644 index 24c381f..0000000 --- a/ui/src/pages/EnvironmentsPage.tsx +++ /dev/null @@ -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[] = [ - { - key: 'displayName', - header: 'Name', - render: (_val, row) => ( - {row.displayName} - ), - }, - { - key: 'slug', - header: 'Slug', - render: (_val, row) => ( - - ), - }, - { - key: 'status', - header: 'Status', - render: (_val, row) => ( - - ), - }, - { - 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 ( -
- -
- ); - } - - return ( -
- {/* Page header */} -
-

Environments

- - - -
- - {/* Table / empty state */} - {tableData.length === 0 ? ( - - - - } - /> - ) : ( - - - columns={columns} - data={tableData} - onRowClick={(row) => navigate(`/environments/${row.id}`)} - flush - /> - - )} - - {/* Create Environment Modal */} - -
- - setDisplayName(e.target.value)} - placeholder="e.g. Production" - autoFocus - required - /> - -
- - -
-
-
-
- ); -} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 6d80437..454d83d 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -5,9 +5,6 @@ import { ProtectedRoute } from './auth/ProtectedRoute'; import { OrgResolver } from './auth/OrgResolver'; 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'; import { AdminTenantsPage } from './pages/AdminTenantsPage'; @@ -26,9 +23,6 @@ export function AppRouter() { } > } /> - } /> - } /> - } /> } /> } /> diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index deb4f8e..5bf8d6c 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -8,48 +8,6 @@ export interface TenantResponse { 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; - memoryLimit: string | null; - cpuShares: number | 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; - deployedAt: string | null; - stoppedAt: string | null; - createdAt: string; -} - export interface LicenseResponse { id: string; tenantId: string; @@ -61,31 +19,6 @@ export interface LicenseResponse { 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; -} - export interface MeResponse { userId: string; tenants: Array<{