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:
@@ -1,9 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type {
|
import type {
|
||||||
TenantResponse, EnvironmentResponse, AppResponse,
|
TenantResponse, LicenseResponse, MeResponse,
|
||||||
DeploymentResponse, LicenseResponse, AgentStatusResponse,
|
|
||||||
ObservabilityStatusResponse, LogEntry, MeResponse,
|
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
// Tenant
|
// Tenant
|
||||||
@@ -24,166 +22,6 @@ export function useLicense(tenantId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environments
|
|
||||||
export function useEnvironments(tenantId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['environments', tenantId],
|
|
||||||
queryFn: () => api.get<EnvironmentResponse[]>(`/tenants/${tenantId}/environments`),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateEnvironment(tenantId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { slug: string; displayName: string }) =>
|
|
||||||
api.post<EnvironmentResponse>(`/tenants/${tenantId}/environments`, data),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateEnvironment(tenantId: string, envId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { displayName: string }) =>
|
|
||||||
api.patch<EnvironmentResponse>(`/tenants/${tenantId}/environments/${envId}`, data),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteEnvironment(tenantId: string, envId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apps
|
|
||||||
export function useApps(environmentId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['apps', environmentId],
|
|
||||||
queryFn: () => api.get<AppResponse[]>(`/environments/${environmentId}/apps`),
|
|
||||||
enabled: !!environmentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApp(environmentId: string, appId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['app', appId],
|
|
||||||
queryFn: () => api.get<AppResponse>(`/environments/${environmentId}/apps/${appId}`),
|
|
||||||
enabled: !!appId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateApp(environmentId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (formData: FormData) =>
|
|
||||||
api.post<AppResponse>(`/environments/${environmentId}/apps`, formData),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteApp(environmentId: string, appId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRouting(environmentId: string, appId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { exposedPort: number | null }) =>
|
|
||||||
api.patch<AppResponse>(`/environments/${environmentId}/apps/${appId}/routing`, data),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deployments
|
|
||||||
export function useDeploy(appId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/deploy`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeployments(appId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['deployments', appId],
|
|
||||||
queryFn: () => api.get<DeploymentResponse[]>(`/apps/${appId}/deployments`),
|
|
||||||
enabled: !!appId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeployment(appId: string, deploymentId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['deployment', deploymentId],
|
|
||||||
queryFn: () => api.get<DeploymentResponse>(`/apps/${appId}/deployments/${deploymentId}`),
|
|
||||||
enabled: !!deploymentId,
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.observedStatus;
|
|
||||||
return status === 'BUILDING' || status === 'STARTING' ? 3000 : false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStop(appId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/stop`),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['deployments', appId] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['app'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestart(appId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/restart`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observability
|
|
||||||
export function useAgentStatus(appId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['agent-status', appId],
|
|
||||||
queryFn: () => api.get<AgentStatusResponse>(`/apps/${appId}/agent-status`),
|
|
||||||
enabled: !!appId,
|
|
||||||
refetchInterval: 15_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useObservabilityStatus(appId: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['observability-status', appId],
|
|
||||||
queryFn: () => api.get<ObservabilityStatusResponse>(`/apps/${appId}/observability-status`),
|
|
||||||
enabled: !!appId,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['logs', appId, params],
|
|
||||||
queryFn: () => {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params?.since) qs.set('since', params.since);
|
|
||||||
if (params?.limit) qs.set('limit', String(params.limit));
|
|
||||||
if (params?.stream) qs.set('stream', params.stream);
|
|
||||||
const query = qs.toString();
|
|
||||||
return api.get<LogEntry[]>(`/apps/${appId}/logs${query ? `?${query}` : ''}`);
|
|
||||||
},
|
|
||||||
enabled: !!appId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
export function useMe() {
|
export function useMe() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Badge } from '@cameleer/design-system';
|
|
||||||
|
|
||||||
// Badge color values: 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
|
|
||||||
const STATUS_COLORS: Record<string, 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'> = {
|
|
||||||
BUILDING: 'warning',
|
|
||||||
STARTING: 'warning',
|
|
||||||
RUNNING: 'running',
|
|
||||||
FAILED: 'error',
|
|
||||||
STOPPED: 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeploymentStatusBadge({ status }: { status: string }) {
|
|
||||||
const color = STATUS_COLORS[status] ?? 'auto';
|
|
||||||
return <Badge label={status} color={color} />;
|
|
||||||
}
|
|
||||||
@@ -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<Set<string>>(new Set());
|
|
||||||
const [envNodes, setEnvNodes] = useState<Map<string, SidebarTreeNode>>(new Map());
|
|
||||||
|
|
||||||
const handleToggleStar = useCallback((id: string) => {
|
|
||||||
setStarred((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNode = useCallback((node: SidebarTreeNode) => {
|
|
||||||
setEnvNodes((prev) => {
|
|
||||||
const existing = prev.get(node.id);
|
|
||||||
// Avoid infinite re-renders: only update when something meaningful changed.
|
|
||||||
if (
|
|
||||||
existing &&
|
|
||||||
existing.label === node.label &&
|
|
||||||
existing.path === node.path &&
|
|
||||||
existing.children?.length === node.children?.length
|
|
||||||
) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return new Map(prev).set(node.id, node);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const envs = environments ?? [];
|
|
||||||
|
|
||||||
// Build the final node list, falling back to env-only nodes until apps load.
|
|
||||||
const nodes: SidebarTreeNode[] = envs.map(
|
|
||||||
(env) =>
|
|
||||||
envNodes.get(env.id) ?? {
|
|
||||||
id: env.id,
|
|
||||||
label: env.displayName,
|
|
||||||
path: `/environments/${env.id}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Invisible data-fetchers: one per environment */}
|
|
||||||
{envs.map((env) => (
|
|
||||||
<EnvWithApps key={env.id} env={env} onNode={handleNode} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SidebarTree
|
|
||||||
nodes={nodes}
|
|
||||||
selectedPath={location.pathname}
|
|
||||||
isStarred={(id) => starred.has(id)}
|
|
||||||
onToggleStar={handleToggleStar}
|
|
||||||
onNavigate={(path) => navigate(path)}
|
|
||||||
persistKey="env-tree"
|
|
||||||
autoRevealPath={location.pathname}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Outlet, useNavigate } from 'react-router';
|
import { Outlet, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useScopes } from '../auth/useScopes';
|
import { useScopes } from '../auth/useScopes';
|
||||||
import { useOrgStore } from '../auth/useOrganization';
|
import { useOrgStore } from '../auth/useOrganization';
|
||||||
import { EnvironmentTree } from './EnvironmentTree';
|
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg';
|
||||||
|
|
||||||
function CameleerLogo() {
|
function CameleerLogo() {
|
||||||
@@ -35,19 +33,6 @@ function DashboardIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EnvIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M2 4h12M2 8h12M2 12h12"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseIcon() {
|
function LicenseIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
@@ -82,11 +67,8 @@ export function Layout() {
|
|||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
const { username } = useOrgStore();
|
const { username } = useOrgStore();
|
||||||
|
|
||||||
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed((c) => !c)}>
|
<Sidebar collapsed={false} onCollapseToggle={() => {}}>
|
||||||
<Sidebar.Header
|
<Sidebar.Header
|
||||||
logo={<CameleerLogo />}
|
logo={<CameleerLogo />}
|
||||||
title="Cameleer SaaS"
|
title="Cameleer SaaS"
|
||||||
@@ -103,16 +85,6 @@ export function Layout() {
|
|||||||
{null}
|
{null}
|
||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
|
|
||||||
{/* Environments — expandable tree */}
|
|
||||||
<Sidebar.Section
|
|
||||||
icon={<EnvIcon />}
|
|
||||||
label="Environments"
|
|
||||||
open={envSectionOpen}
|
|
||||||
onToggle={() => setEnvSectionOpen((o) => !o)}
|
|
||||||
>
|
|
||||||
<EnvironmentTree />
|
|
||||||
</Sidebar.Section>
|
|
||||||
|
|
||||||
{/* License */}
|
{/* License */}
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
icon={<LicenseIcon />}
|
icon={<LicenseIcon />}
|
||||||
@@ -136,10 +108,10 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
{/* Link to the observability SPA (direct port, not via Traefik prefix) */}
|
{/* Link to the server observability dashboard */}
|
||||||
<Sidebar.FooterLink
|
<Sidebar.FooterLink
|
||||||
icon={<ObsIcon />}
|
icon={<ObsIcon />}
|
||||||
label="View Dashboard"
|
label="Open Server Dashboard"
|
||||||
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
||||||
/>
|
/>
|
||||||
</Sidebar.Footer>
|
</Sidebar.Footer>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -9,26 +7,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useTenant, useEnvironments, useApps } from '../api/hooks';
|
import { useTenant, useLicense } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
||||||
switch (tier?.toLowerCase()) {
|
switch (tier?.toLowerCase()) {
|
||||||
@@ -40,56 +19,30 @@ function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { tenantId } = useAuth();
|
const { tenantId } = useAuth();
|
||||||
|
|
||||||
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
|
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 isLoading = tenantLoading || licenseLoading;
|
||||||
const [appsByEnv, setAppsByEnv] = useState<Record<string, AppResponse[]>>({});
|
|
||||||
|
|
||||||
const handleAppsData = useCallback((envId: string, apps: AppResponse[]) => {
|
|
||||||
setAppsByEnv((prev) => {
|
|
||||||
if (prev[envId] === apps) return prev; // stable reference, no update
|
|
||||||
return { ...prev, [envId]: apps };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allApps = Object.values(appsByEnv).flat();
|
|
||||||
const runningApps = allApps.filter((a) => a.currentDeploymentId !== null);
|
|
||||||
// "Failed" is apps that have a previous deployment but no current (stopped) — approximate heuristic
|
|
||||||
const failedApps = allApps.filter(
|
|
||||||
(a) => a.currentDeploymentId === null && a.previousDeploymentId !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = tenantLoading || envsLoading;
|
|
||||||
|
|
||||||
const kpiItems = [
|
const kpiItems = [
|
||||||
{
|
{
|
||||||
label: 'Environments',
|
label: 'Tier',
|
||||||
value: environments?.length ?? 0,
|
value: tenant?.tier ?? '-',
|
||||||
subtitle: 'isolated runtime contexts',
|
subtitle: 'subscription level',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Total Apps',
|
label: 'Status',
|
||||||
value: allApps.length,
|
value: tenant?.status ?? '-',
|
||||||
subtitle: 'across all environments',
|
subtitle: 'tenant status',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Running',
|
label: 'License',
|
||||||
value: runningApps.length,
|
value: license ? 'Active' : 'None',
|
||||||
trend: {
|
trend: license
|
||||||
label: 'active deployments',
|
? { label: `expires ${new Date(license.expiresAt).toLocaleDateString()}`, variant: 'success' as const }
|
||||||
variant: 'success' as const,
|
: { label: 'no license', variant: 'warning' as const },
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Stopped',
|
|
||||||
value: failedApps.length,
|
|
||||||
trend: failedApps.length > 0
|
|
||||||
? { label: 'need attention', variant: 'warning' as const }
|
|
||||||
: { label: 'none', variant: 'muted' as const },
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -125,97 +78,52 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
||||||
>
|
>
|
||||||
View Observability Dashboard
|
Open Server Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Strip */}
|
{/* KPI Strip */}
|
||||||
<KpiStrip items={kpiItems} />
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
{/* Environments overview */}
|
{/* Tenant Info */}
|
||||||
{environments && environments.length > 0 ? (
|
<Card title="Tenant Information">
|
||||||
<Card title="Environments">
|
<div className="space-y-2 text-sm">
|
||||||
{/* Render hidden data-fetchers for each environment */}
|
<div className="flex justify-between text-white/80">
|
||||||
{environments.map((env) => (
|
<span>Slug</span>
|
||||||
<EnvApps key={env.id} environment={env} onData={handleAppsData} />
|
<span className="font-mono">{tenant?.slug ?? '-'}</span>
|
||||||
))}
|
</div>
|
||||||
<div className="divide-y divide-white/10">
|
<div className="flex justify-between text-white/80">
|
||||||
{environments.map((env) => {
|
<span>Status</span>
|
||||||
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
|
<Badge
|
||||||
label={env.slug}
|
label={tenant?.status ?? 'UNKNOWN'}
|
||||||
color="primary"
|
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-white/60">
|
<div className="flex justify-between text-white/80">
|
||||||
<span>{envApps.length} apps</span>
|
<span>Created</span>
|
||||||
<span className="text-green-400">{envRunning} running</span>
|
<span>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span>
|
||||||
<Badge
|
|
||||||
label={env.status}
|
|
||||||
color={env.status === 'ACTIVE' ? 'success' : 'warning'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent deployments placeholder */}
|
{/* Server Dashboard Link */}
|
||||||
<Card title="Recent Deployments">
|
<Card title="Server Management">
|
||||||
{allApps.length === 0 ? (
|
<p className="text-sm text-white/60 mb-3">
|
||||||
<EmptyState
|
Environments, applications, and deployments are managed through the server dashboard.
|
||||||
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>
|
</p>
|
||||||
)}
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
||||||
|
>
|
||||||
|
Open Server Dashboard
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,6 @@ import { ProtectedRoute } from './auth/ProtectedRoute';
|
|||||||
import { OrgResolver } from './auth/OrgResolver';
|
import { OrgResolver } from './auth/OrgResolver';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
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 { LicensePage } from './pages/LicensePage';
|
||||||
import { AdminTenantsPage } from './pages/AdminTenantsPage';
|
import { AdminTenantsPage } from './pages/AdminTenantsPage';
|
||||||
|
|
||||||
@@ -26,9 +23,6 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="environments" element={<EnvironmentsPage />} />
|
|
||||||
<Route path="environments/:envId" element={<EnvironmentDetailPage />} />
|
|
||||||
<Route path="environments/:envId/apps/:appId" element={<AppDetailPage />} />
|
|
||||||
<Route path="license" element={<LicensePage />} />
|
<Route path="license" element={<LicensePage />} />
|
||||||
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -8,48 +8,6 @@ export interface TenantResponse {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvironmentResponse {
|
|
||||||
id: string;
|
|
||||||
tenantId: string;
|
|
||||||
slug: string;
|
|
||||||
displayName: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppResponse {
|
|
||||||
id: string;
|
|
||||||
environmentId: string;
|
|
||||||
slug: string;
|
|
||||||
displayName: string;
|
|
||||||
jarOriginalFilename: string | null;
|
|
||||||
jarSizeBytes: number | null;
|
|
||||||
jarChecksum: string | null;
|
|
||||||
exposedPort: number | null;
|
|
||||||
routeUrl: string | null;
|
|
||||||
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<string, unknown>;
|
|
||||||
deployedAt: string | null;
|
|
||||||
stoppedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LicenseResponse {
|
export interface LicenseResponse {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -61,31 +19,6 @@ export interface LicenseResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStatusResponse {
|
|
||||||
registered: boolean;
|
|
||||||
state: string;
|
|
||||||
lastHeartbeat: string | null;
|
|
||||||
routeIds: string[];
|
|
||||||
applicationId: string;
|
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObservabilityStatusResponse {
|
|
||||||
hasTraces: boolean;
|
|
||||||
hasMetrics: boolean;
|
|
||||||
hasDiagrams: boolean;
|
|
||||||
lastTraceAt: string | null;
|
|
||||||
traceCount24h: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogEntry {
|
|
||||||
appId: string;
|
|
||||||
deploymentId: string;
|
|
||||||
timestamp: string;
|
|
||||||
stream: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
userId: string;
|
userId: string;
|
||||||
tenants: Array<{
|
tenants: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user