feat: unified catalog endpoint and slug-based app navigation
All checks were successful
All checks were successful
Consolidate route catalog (agent-driven) and apps table (deployment-
driven) into a single GET /api/v1/catalog?environment={slug} endpoint.
Apps table is authoritative; agent data enriches with live health,
routes, and metrics. Unmanaged apps (agents without App record) appear
with managed=false.
- Add CatalogController merging App records + agent registry + ClickHouse
- Add CatalogApp DTO with deployment summary, managed flag, health
- Change AppController and DeploymentController to accept slugs (not UUIDs)
- Add AppRepository.findBySlug() and AppService.getBySlug()
- Replace useRouteCatalog() with useCatalog() across all UI components
- Navigate to /apps/{slug} instead of /apps/{UUID}
- Update sidebar, search, and all catalog lookups to use slug
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,9 +92,12 @@ export function useCreateApp() {
|
||||
export function useDeleteApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
appFetch<void>(`/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||
mutationFn: (slug: string) =>
|
||||
appFetch<void>(`/${slug}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['catalog'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,25 +3,57 @@ import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useRefreshInterval } from './use-refresh-interval';
|
||||
|
||||
export function useRouteCatalog(from?: string, to?: string, environment?: string) {
|
||||
export interface CatalogRoute {
|
||||
routeId: string;
|
||||
exchangeCount: number;
|
||||
lastSeen: string | null;
|
||||
fromEndpointUri: string | null;
|
||||
routeState: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogAgent {
|
||||
instanceId: string;
|
||||
displayName: string;
|
||||
state: string;
|
||||
tps: number;
|
||||
}
|
||||
|
||||
export interface DeploymentSummary {
|
||||
status: string;
|
||||
replicas: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CatalogApp {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
managed: boolean;
|
||||
environmentSlug: string;
|
||||
health: 'live' | 'stale' | 'dead' | 'offline';
|
||||
agentCount: number;
|
||||
routes: CatalogRoute[];
|
||||
agents: CatalogAgent[];
|
||||
exchangeCount: number;
|
||||
deployment: DeploymentSummary | null;
|
||||
}
|
||||
|
||||
export function useCatalog(environment?: string) {
|
||||
const refetchInterval = useRefreshInterval(15_000);
|
||||
return useQuery({
|
||||
queryKey: ['routes', 'catalog', from, to, environment],
|
||||
queryKey: ['catalog', environment],
|
||||
queryFn: async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (environment) params.set('environment', environment);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${config.apiBaseUrl}/routes/catalog${qs ? `?${qs}` : ''}`, {
|
||||
const res = await fetch(`${config.apiBaseUrl}/catalog${qs ? `?${qs}` : ''}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load route catalog');
|
||||
return res.json();
|
||||
if (!res.ok) throw new Error('Failed to load catalog');
|
||||
return res.json() as Promise<CatalogApp[]>;
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
refetchInterval,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User } f
|
||||
import { AboutMeDialog } from './AboutMeDialog';
|
||||
import css from './LayoutShell.module.css';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
||||
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
||||
@@ -56,24 +56,26 @@ function buildSearchData(
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const app of catalog) {
|
||||
const slug = app.slug || app.appId;
|
||||
const name = app.displayName || slug;
|
||||
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
|
||||
results.push({
|
||||
id: app.appId,
|
||||
id: slug,
|
||||
category: 'application',
|
||||
title: app.appId,
|
||||
title: name,
|
||||
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }],
|
||||
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/exchanges/${app.appId}`,
|
||||
path: `/exchanges/${slug}`,
|
||||
});
|
||||
|
||||
for (const route of (app.routes || [])) {
|
||||
results.push({
|
||||
id: `${app.appId}/${route.routeId}`,
|
||||
id: `${slug}/${route.routeId}`,
|
||||
category: 'route',
|
||||
title: route.routeId,
|
||||
badges: [{ label: app.appId }],
|
||||
badges: [{ label: name }],
|
||||
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/exchanges/${app.appId}/${route.routeId}`,
|
||||
path: `/exchanges/${slug}/${route.routeId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -288,7 +290,7 @@ function LayoutContent() {
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
||||
|
||||
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString(), selectedEnv);
|
||||
const { data: catalog } = useCatalog(selectedEnv);
|
||||
const { data: allAgents } = useAgents(); // unfiltered — for environment discovery
|
||||
const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search
|
||||
const { data: attributeKeys } = useAttributeKeys();
|
||||
@@ -397,11 +399,11 @@ function LayoutContent() {
|
||||
if (!catalog) return [];
|
||||
const cmp = (a: string, b: string) => a.localeCompare(b);
|
||||
return [...catalog]
|
||||
.sort((a: any, b: any) => cmp(a.appId, b.appId))
|
||||
.sort((a: any, b: any) => cmp(a.slug, b.slug))
|
||||
.map((app: any) => ({
|
||||
id: app.appId,
|
||||
name: app.appId,
|
||||
health: app.health as 'live' | 'stale' | 'dead',
|
||||
id: app.slug,
|
||||
name: app.displayName || app.slug,
|
||||
health: (app.health === 'offline' ? 'dead' : app.health) as 'live' | 'stale' | 'dead',
|
||||
exchangeCount: app.exchangeCount,
|
||||
routes: [...(app.routes || [])]
|
||||
.sort((a: any, b: any) => cmp(a.routeId, b.routeId))
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
||||
import styles from './AppConfigDetailPage.module.css';
|
||||
|
||||
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
|
||||
@@ -75,7 +75,7 @@ export default function AppConfigDetailPage() {
|
||||
const { toast } = useToast();
|
||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: catalog } = useCatalog();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
||||
@@ -83,9 +83,9 @@ export default function AppConfigDetailPage() {
|
||||
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Find routes for this application from the catalog
|
||||
const appRoutes: RouteSummary[] = useMemo(() => {
|
||||
const appRoutes: CatalogRoute[] = useMemo(() => {
|
||||
if (!catalog || !appId) return [];
|
||||
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === appId);
|
||||
const entry = (catalog as CatalogApp[]).find((e) => e.slug === appId);
|
||||
return entry?.routes ?? [];
|
||||
}, [catalog, appId]);
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
|
||||
import type { Environment } from '../../api/queries/admin/environments';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
||||
import { DeploymentProgress } from '../../components/DeploymentProgress';
|
||||
import styles from './AppsTab.module.css';
|
||||
|
||||
@@ -122,7 +122,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
|
||||
<div className={styles.toolbar}>
|
||||
<Button size="sm" variant="primary" onClick={() => navigate('/apps/new')}>+ Create App</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
|
||||
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.slug}`)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,7 +217,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
|
||||
// 2. Upload JAR
|
||||
setStep('Uploading JAR...');
|
||||
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
|
||||
const version = await uploadJar.mutateAsync({ appId: app.slug, file: file! });
|
||||
|
||||
// 3. Save container config
|
||||
setStep('Saving configuration...');
|
||||
@@ -234,7 +234,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
stripPathPrefix: stripPrefix,
|
||||
sslOffloading: sslOffloading,
|
||||
};
|
||||
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
|
||||
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
|
||||
|
||||
// 4. Save agent config (will be pushed to agent on first connect)
|
||||
setStep('Saving monitoring config...');
|
||||
@@ -257,11 +257,11 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
// 5. Deploy (if requested)
|
||||
if (deploy) {
|
||||
setStep('Starting deployment...');
|
||||
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
|
||||
await createDeployment.mutateAsync({ appId: app.slug, appVersionId: version.id, environmentId: envId });
|
||||
}
|
||||
|
||||
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
|
||||
navigate(`/apps/${app.id}`);
|
||||
navigate(`/apps/${app.slug}`);
|
||||
} catch (e) {
|
||||
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
|
||||
} finally {
|
||||
@@ -476,13 +476,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
// DETAIL VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function AppDetailView({ appId, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
|
||||
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: allApps = [] } = useAllApps();
|
||||
const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]);
|
||||
const { data: versions = [] } = useAppVersions(appId);
|
||||
const { data: deployments = [] } = useDeployments(appId);
|
||||
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
|
||||
const { data: versions = [] } = useAppVersions(appSlug);
|
||||
const { data: deployments = [] } = useDeployments(appSlug);
|
||||
const uploadJar = useUploadJar();
|
||||
const createDeployment = useCreateDeployment();
|
||||
const stopDeployment = useStopDeployment();
|
||||
@@ -502,7 +502,7 @@ function AppDetailView({ appId, environments, selectedEnv }: { appId: string; en
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const v = await uploadJar.mutateAsync({ appId, file });
|
||||
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
|
||||
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
||||
} catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); }
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
@@ -510,21 +510,21 @@ function AppDetailView({ appId, environments, selectedEnv }: { appId: string; en
|
||||
|
||||
async function handleDeploy(versionId: string, environmentId: string) {
|
||||
try {
|
||||
await createDeployment.mutateAsync({ appId, appVersionId: versionId, environmentId });
|
||||
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
|
||||
toast({ title: 'Deployment started', variant: 'success' });
|
||||
} catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); }
|
||||
}
|
||||
|
||||
async function handleStop(deploymentId: string) {
|
||||
try {
|
||||
await stopDeployment.mutateAsync({ appId, deploymentId });
|
||||
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId });
|
||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteApp.mutateAsync(appId);
|
||||
await deleteApp.mutateAsync(appSlug);
|
||||
toast({ title: 'App deleted', variant: 'warning' });
|
||||
navigate('/apps');
|
||||
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
|
||||
@@ -698,15 +698,15 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug);
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const updateContainerConfig = useUpdateContainerConfig();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: catalog } = useCatalog();
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
|
||||
const isProd = environment?.production ?? false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'traces' | 'recording' | 'resources'>('variables');
|
||||
|
||||
const appRoutes: RouteSummary[] = useMemo(() => {
|
||||
const appRoutes: CatalogRoute[] = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === app.slug);
|
||||
const entry = (catalog as CatalogApp[]).find((e) => e.slug === app.slug);
|
||||
return entry?.routes ?? [];
|
||||
}, [catalog, app.slug]);
|
||||
|
||||
@@ -819,7 +819,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
sslOffloading: sslOffloading,
|
||||
};
|
||||
try {
|
||||
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
|
||||
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
|
||||
toast({ title: 'Configuration saved', variant: 'success' });
|
||||
setEditing(false);
|
||||
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GitBranch, Server, RotateCcw, FileText } from 'lucide-react';
|
||||
import { StatusDot, MonoText, Badge, useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import { useCanControl } from '../../auth/auth-store';
|
||||
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color';
|
||||
@@ -52,11 +52,11 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
||||
const attrs = Object.entries(detail.attributes ?? {});
|
||||
|
||||
// Look up route state from catalog
|
||||
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
|
||||
const { data: catalog } = useCatalog();
|
||||
const routeState = useMemo(() => {
|
||||
if (!catalog) return undefined;
|
||||
for (const app of catalog as any[]) {
|
||||
if (app.appId !== detail.applicationId) continue;
|
||||
if (app.slug !== detail.applicationId) continue;
|
||||
for (const route of app.routes || []) {
|
||||
if (route.routeId === detail.routeId) {
|
||||
return (route.routeState ?? 'started') as 'started' | 'stopped' | 'suspended';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useLocation, useParams } from 'react-router';
|
||||
import { useGlobalFilters, useToast } from '@cameleer/design-system';
|
||||
import { useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
||||
@@ -150,7 +150,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
const { data: detail } = useExecutionDetail(exchangeId ?? null);
|
||||
const diagramQuery = useDiagramByRoute(appId, routeId);
|
||||
|
||||
const { data: catalog } = useRouteCatalog(timeFrom, timeTo);
|
||||
const { data: catalog } = useCatalog();
|
||||
|
||||
// Route state + capabilities for topology-only control bar
|
||||
const { data: agents } = useAgents(undefined, appId);
|
||||
@@ -166,7 +166,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
const routeState = useMemo(() => {
|
||||
if (!catalog) return undefined;
|
||||
for (const app of catalog as any[]) {
|
||||
if (app.applicationId !== appId) continue;
|
||||
if (app.slug !== appId) continue;
|
||||
for (const r of app.routes || []) {
|
||||
if (r.routeId === routeId) return (r.routeState ?? 'started') as 'started' | 'stopped' | 'suspended';
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ import {
|
||||
} from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
||||
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands';
|
||||
import type { TapDefinition } from '../../api/queries/commands';
|
||||
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
||||
import { buildFlowSegments } from '../../utils/diagram-mapping';
|
||||
import styles from './RouteDetail.module.css';
|
||||
|
||||
@@ -300,7 +301,7 @@ export default function RouteDetail() {
|
||||
}, []);
|
||||
|
||||
// ── API queries ────────────────────────────────────────────────────────────
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: catalog } = useCatalog();
|
||||
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
||||
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
@@ -340,13 +341,13 @@ export default function RouteDetail() {
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
||||
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
||||
const appEntry: CatalogApp | undefined = useMemo(() =>
|
||||
(catalog || []).find((e: CatalogApp) => e.slug === appId),
|
||||
[catalog, appId],
|
||||
);
|
||||
|
||||
const routeSummary: RouteSummary | undefined = useMemo(() =>
|
||||
appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId),
|
||||
const routeSummary: CatalogRoute | undefined = useMemo(() =>
|
||||
appEntry?.routes?.find((r: CatalogRoute) => r.routeId === routeId),
|
||||
[appEntry, routeId],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user