Files
cameleer-server/docs/superpowers/specs/2026-04-08-catalog-consolidation-design.md
hsiegeln 0720053523 docs: add catalog consolidation design spec
Unify route catalog (agent-driven) and apps table (deployment-driven)
into a single catalog endpoint. Apps table becomes authoritative,
agent data enriches with live health/routes. Slug-based URLs replace
UUIDs for navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:32:18 +02:00

5.3 KiB

Catalog & App Consolidation Design

Problem

Two independent data structures represent "applications":

  1. Route Catalog — built from agent registry + ClickHouse stats. Uses string applicationId (e.g., "sample-app"). Contains live routes, agents, health, exchange counts.
  2. Apps table — PostgreSQL CRUD. Uses UUID id + string slug. Contains containerConfig, deployment info, JAR versions.

No bridge between them. The sidebar navigates to /apps/sample-app (catalog string) but the API expects /apps/{UUID}, causing 400 errors. The UI has separate useRouteCatalog() and useAllApps() hooks that never talk to each other.

Design Decisions

  • Apps table is authoritative for "what applications exist" per environment
  • Agent data enriches managed apps with live health, routes, metrics
  • Unmanaged apps (agents without an App record) appear in the catalog with managed: false and a "Register" prompt on the Deployments tab
  • Slugs in URLs everywhere — no UUIDs in navigation
  • Tab-aware sidebar stays as-is — the app tree just comes from one unified source
  • Environment-scopedGET /api/v1/catalog?environment={slug} is the single source

Unified Catalog Endpoint

GET /api/v1/catalog?environment={envSlug}

Returns:

[
  {
    "slug": "sample-app",
    "displayName": "Sample App",
    "managed": true,
    "environmentSlug": "default",
    "health": "live",
    "agentCount": 1,
    "routes": [
      { "routeId": "route1", "name": "timer-route", "exchangeCount": 42, "routeState": "started" }
    ],
    "exchangeCount": 42,
    "deployment": {
      "status": "RUNNING",
      "replicas": "1/1",
      "version": 1
    }
  }
]

Build logic

  1. Query App records filtered by environmentId (from env slug)
  2. Query agent registry — filter by environmentId, group by applicationId
  3. For each App record: find matching agent group by App.slug == AgentInfo.applicationId, merge live data
  4. For agent groups with no matching App record: add as unmanaged entry
  5. Enrich with ClickHouse exchange counts per app

CatalogApp fields

Field Source Notes
slug App.slug or AgentInfo.applicationId Universal identifier
displayName App.displayName or slug Fallback to slug for unmanaged
managed App record exists boolean
environmentSlug Environment.slug From env lookup
health Agent registry live/stale/dead/offline (offline = no agents)
agentCount Agent registry Count of agents with matching applicationId
routes Agent registry Live routes from connected agents
exchangeCount ClickHouse stats Total exchanges
deployment Deployment record null if unmanaged or no deployment

Slug-Based API Routing

All app-scoped controllers accept slug (String) instead of UUID:

  • GET /api/v1/apps/{slug} — app detail
  • GET /api/v1/apps/{slug}/versions — JAR versions
  • POST /api/v1/apps/{slug}/versions — upload JAR
  • GET /api/v1/apps/{slug}/deployments — deployment list
  • POST /api/v1/apps/{slug}/deployments — trigger deploy
  • POST /api/v1/apps/{slug}/deployments/{deploymentId}/stop — stop

The controller resolves slug to App record via AppRepository.findByEnvironmentIdAndSlug(). Environment comes from the selected environment (query param or header).

DeploymentController path changes from /api/v1/apps/{appId}/deployments to /api/v1/apps/{slug}/deployments.

URL Routing (Frontend)

All app URLs use slug:

Before After
/exchanges/{catalogAppId} /exchanges/{slug} (no change, already string)
/dashboard/{catalogAppId} /dashboard/{slug} (no change)
/apps/{UUID} /apps/{slug}
/apps/{UUID}/deployments implicit in /apps/{slug} detail view

Frontend Changes

New hook: useCatalog(environment)

Replaces both useRouteCatalog() and useAllApps().

export function useCatalog(environment?: string) {
  return useQuery({
    queryKey: ['catalog', environment],
    queryFn: () => fetchCatalog(environment),
    enabled: !!environment,
    refetchInterval: 15_000,
  });
}

Sidebar

buildAppTreeNodes() receives CatalogApp[] instead of SidebarApp[]. The SidebarApp.id becomes the slug. Navigation paths stay the same pattern: /exchanges/{slug}, etc.

AppsTab

AppDetailView receives slug from URL params. Looks up the app from the catalog data. If managed: true, shows deployments/versions/config. If managed: false, shows agent data + "Register this app" button.

AppListView uses catalog data filtered to the current environment, showing managed status as a badge.

What Gets Removed

  • RouteCatalogController — replaced by CatalogController
  • AppCatalogEntry DTO — replaced by CatalogApp
  • useRouteCatalog() hook — replaced by useCatalog()
  • useAllApps() hook — replaced by useCatalog()
  • Separate SidebarApp type — catalog data is sufficient
  • UUID-based app URL navigation

What Stays

  • AppController — still needed for CRUD (create, update, delete apps)
  • AppService, AppRepository — unchanged
  • DeploymentController — path variable changes from UUID to slug
  • Agent registry — unchanged, still tracks live agents
  • ClickHouse stats — unchanged, still queried for exchange counts
  • Environment selector — unchanged, drives the catalog filter