diff --git a/docs/superpowers/specs/2026-04-08-catalog-consolidation-design.md b/docs/superpowers/specs/2026-04-08-catalog-consolidation-design.md new file mode 100644 index 00000000..4d455027 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-catalog-consolidation-design.md @@ -0,0 +1,140 @@ +# 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-scoped** — `GET /api/v1/catalog?environment={slug}` is the single source + +## Unified Catalog Endpoint + +`GET /api/v1/catalog?environment={envSlug}` + +Returns: + +```json +[ + { + "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()`. + +```typescript +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