From 07c23be2a712a575f2a062db5034fa58e12b0b52 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Wed, 17 Jun 2026 18:06:55 -0400 Subject: [PATCH 1/2] feat(app-detail): make the Overview tab consumer-visible for every app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview tab previously rendered only from a deployment manifest (`Pipeline`), so it appeared only for the org's own deployed apps and never as the default tab on the consumer base — meaning the public apps a consumer browses from Explore had no Overview at all. Render Overview from the catalog `model` (App), which exists for every app, and treat the deployment manifest as optional enrichment: - Headline stats (Calls·7d, p50 latency, uptime, warm orchestrators) come from the model, so they show for any app. Warm-orchestrator count is a liveness/capacity signal for the caller. - Endpoint card shows the call URL for every app; per-route rows appear when the deployment manifest is known. - Deployment-backed apps additionally get the full Deployment card (pipeline id, entrypoint, image, version, GPU, last deployed, deployed by, environments) + the request Schema — shown to any viewer. - Catalog-only apps get a lighter Details card (provider, category, type, pricing) so the tab isn't bare. Overview is now available to everyone and is the default landing tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/apps/[id]/page.tsx | 31 +-- components/dashboard/AppDetailView.tsx | 261 ++++++++++++++----------- 2 files changed, 170 insertions(+), 122 deletions(-) diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx index e9ac14c..e61784f 100644 --- a/app/(app)/apps/[id]/page.tsx +++ b/app/(app)/apps/[id]/page.tsx @@ -73,9 +73,10 @@ type TabSpec = { count?: number; }; -// Overview leads when the app is deployment-backed (so there's something to -// show). It's a read-only summary — KPIs + deployment metadata — viewable by -// anyone who can see the app, not just the owner. +// Overview is the app's front page and the default tab for everyone. It's a +// read-only summary — headline stats + how to call it, plus deployment metadata +// when the app is deployment-backed — rendered from the catalog model, so it's +// available for any app, not just the org's own deployments. const OVERVIEW_TAB: TabSpec = { key: "overview", label: "Overview", icon: Box }; const TABS: TabSpec[] = [ @@ -634,19 +635,21 @@ export default function AppDetailPage() { ); }, [model]); - // Tab set: Overview (when deployment-backed) + the consumer tabs are shown to - // everyone; owners additionally get the Settings trail. + // Tab set: Overview + the consumer tabs are shown to everyone (Overview + // renders from the catalog model, so it's there for any app); owners + // additionally get the Settings trail. const tabs: TabSpec[] = useMemo(() => { const consumer = TABS.map((t) => t.key === "jobs" ? { ...t, count: filteredRuns.length } : t, ); - const base = pipeline ? [OVERVIEW_TAB, ...consumer] : consumer; + const base = [OVERVIEW_TAB, ...consumer]; return isOwner ? [...base, ...OWNER_TABS] : base; - }, [filteredRuns.length, isOwner, pipeline]); + }, [filteredRuns.length, isOwner]); - // Default landing tab: owners land on Overview (the console chrome); everyone - // else on Playground. A `?tab=` param overrides when the viewer has that tab. - const defaultTab: Tab = isOwner ? "overview" : "playground"; + // Default landing tab: Overview — the app's front page (what it is, how it + // performs, how to call it). A `?tab=` param overrides when the viewer has + // that tab. + const defaultTab: Tab = "overview"; const [activeTab, setActiveTab] = useState(defaultTab); useEffect(() => { const requested = new URLSearchParams(window.location.search).get( @@ -897,9 +900,11 @@ export default function AppDetailPage() { aria-labelledby={`tab-${activeTab}`} > {/* Overview (read-only summary, shown to anyone) + Settings (owner - only) reuse the deployment-console chrome verbatim. */} - {activeTab === "overview" && pipeline && ( - + only) reuse the deployment-console chrome verbatim. Overview + renders from the catalog model, so it works for any app; the + deployment manifest, when present, enriches it. */} + {activeTab === "overview" && ( + )} {activeTab === "settings" && pipeline && ( { - if (app.kind === "live") { + if (!pipeline) return null; + if (pipeline.kind === "live") { return JSON.stringify( { - pipeline_id: app.pipelineId, + pipeline_id: pipeline.pipelineId, transport: "trickle", channels: ["video", "events", "data"], params: { type: "object", additionalProperties: true }, @@ -137,102 +162,114 @@ export function OverviewTab({ app }: { app: Pipeline }) { } return JSON.stringify( { - pipeline_id: app.pipelineId, + pipeline_id: pipeline.pipelineId, input: { type: "object", required: ["input"] }, output: { type: "object" }, - streaming: app.name.includes("SSE"), + streaming: pipeline.name.includes("SSE"), }, null, 2, ); - }, [app]); + }, [pipeline]); return (
+ {/* Headline stats — from the catalog model, available for every app. + Warm-orchestrator count is a liveness/capacity signal for the caller. */} - + 0 ? String(app.p50LatencyMs) : "—"} - unit={app.p50LatencyMs > 0 ? "ms" : undefined} - /> - 0 ? String(model.latency) : "—"} + unit={model.latency > 0 ? "ms" : undefined} /> + - - - {app.pipelineId} - - - {app.entrypoint} - - - - {app.image} - - - - {app.version} - - - {app.gpu ? ( - {app.gpu} - ) : ( - CPU - )} - - {formatDeployed(app.lastDeployedAt)} - - - - - - - {deploymentsForPipeline(app.pipelineId).map((d) => { - const env = getEnvironmentById(d.environmentId); - const dotColor = - env?.kind === "production" - ? "var(--color-green-bright)" - : "var(--color-blue-bright)"; - return ( - + + + {pipeline.version} + + + {pipeline.gpu ? ( + {pipeline.gpu} + ) : ( + CPU + )} + + + {formatDeployed(pipeline.lastDeployedAt)} + + + + + {pipeline.createdBy.name} + + + + + {deploymentsForPipeline(pipeline.pipelineId).map((d) => { + const env = getEnvironmentById(d.environmentId); + const dotColor = + env?.kind === "production" + ? "var(--color-green-bright)" + : "var(--color-blue-bright)"; + return ( - ); - })} - - - + key={d.id} + className="inline-flex items-center gap-1.5 rounded-full border border-hairline px-2 py-0.5 text-[11.5px] text-fg-strong" + > + + ); + })} + + + + ) : ( + + {model.provider} + + {model.category} + + + {isLive ? "Live · streaming" : "Batch · request/response"} + + + {formatPrice(model)} + + + )} -
+
{baseUrl} @@ -240,35 +277,39 @@ export function OverviewTab({ app }: { app: Pipeline }) {
-
- {app.endpoints.map((e) => ( -
- - {e.method} - - - {e.path} - - {e.description && ( - - {e.description} + {pipeline && ( +
+ {pipeline.endpoints.map((e) => ( +
+ + {e.method} - )} -
- ))} -
+ + {e.path} + + {e.description && ( + + {e.description} + + )} +
+ ))} +
+ )} - -
-
-            {schema}
-          
-
-
+ {schema && ( + +
+
+              {schema}
+            
+
+
+ )}
); } @@ -763,7 +804,9 @@ export default function AppDetailView({ appId }: { appId: string }) { {/* Tab content */}
- {tab === "overview" && } + {tab === "overview" && ( + + )} {tab === "logs" && } {tab === "settings" && ( Date: Wed, 17 Jun 2026 18:38:49 -0400 Subject: [PATCH 2/2] refactor(data): unify App and Pipeline into one app type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two disjoint types — `App` (static catalog model) and `Pipeline` (a deployed-app manifest) — with separate arrays and separate lookups, even though the product vocabulary says "an app is a deployed pipeline." This makes the data match the vocabulary: one app type, one catalog array, one lookup, and every app carries its pipeline manifest. - `App` keeps all its catalog fields and gains `deployment?: AppDeployment` (the former `Pipeline`-only manifest: pipelineId, entrypoint, image, version, gpu, endpoints, createdBy, environment, kind, status, visibility, warm orchestrators, calls/latency/error metrics). - `Pipeline` is now a transitional alias — `App & { deployment }` — so operator components keep their prop types while reads migrate to `app.deployment.X`. - One `APPS` array = third-party catalog models (each given a derived manifest) + the org's own deployed apps (manifest lifted from the old PIPELINES literal). One `getAppById`; `getModelById` / `getCapabilityById` / `getPipelineById` are thin deprecated aliases. - Helpers (`publicPipelines`, `publicCatalog`, `deploymentsForPipeline`, `PIPELINE_APP_IDS`, visibility) preserve their prior meaning over the unified array — Explore shows the same set, multi-env "Deployed in" pills intact, no private apps leak. - The app-detail Overview now takes a single `app: App` and reads `app.deployment`. Catalog models therefore surface a (synthetic, mock) deployment manifest too, consistent with "every app is a pipeline." Verified: typecheck, lint (0 warnings), build all pass; Explore (20 cards), catalog + owned app detail, and Home all render correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/apps/[id]/page.tsx | 99 +++---- components/dashboard/AppDetailView.tsx | 149 +++++----- components/dashboard/ExploreView.tsx | 21 +- lib/dashboard/mock-data.ts | 382 +++++++++++++++++-------- lib/dashboard/org-fleet.ts | 33 ++- lib/dashboard/types.ts | 44 +-- 6 files changed, 442 insertions(+), 286 deletions(-) diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx index e61784f..6c2d313 100644 --- a/app/(app)/apps/[id]/page.tsx +++ b/app/(app)/apps/[id]/page.tsx @@ -22,9 +22,7 @@ import KeyBadge from "@/components/dashboard/KeyBadge"; import CallsTable from "@/components/dashboard/CallsTable"; import StatusDot from "@/components/dashboard/StatusDot"; import { - getCapabilityById, - getPipelineById, - pipelineToExploreApp, + getAppById, effectiveVisibility, setPipelineVisibility, organizationSlug, @@ -44,7 +42,7 @@ import { OverviewTab, SettingsTab, } from "@/components/dashboard/AppDetailView"; -import type { App, PipelineVisibility } from "@/lib/dashboard/types"; +import type { App, Pipeline, PipelineVisibility } from "@/lib/dashboard/types"; // ─── Tabs ─── // @@ -595,45 +593,41 @@ export default function AppDetailPage() { const { id } = useParams<{ id: string }>(); const { isConnected } = useAuth(); - // The owned deployment (exists for your apps, public or private) and the - // public catalog entry (exists for any listed app). The catalog object is the - // render base; for a private app with no catalog listing we derive it from the - // pipeline so the same template still works. - const pipeline = getPipelineById(id); + // The unified app — its catalog face and (always, since unification) its + // deployment manifest under `app.deployment`. One id-based lookup resolves + // both the org's own apps and the third-party catalog models. + const app = getAppById(id); // Owner/operator chrome (Settings/manage tab, publish controls) lives in the // stacked apps PR. In the consumer base the app detail is view-only for // everyone, so owner mode is gated off here; the stacked PR's revert removes // this flag to re-enable ownership. const OWNER_MODE_ENABLED = false; const isOwner = OWNER_MODE_ENABLED && isConnected && PIPELINE_APP_IDS.has(id); - const model = - getCapabilityById(id) ?? - (pipeline ? pipelineToExploreApp(pipeline) : undefined); // Visibility (publish state) for the owner Settings tab. const [visibility, setVisibility] = useState( - pipeline?.visibility ?? "private", + app?.deployment?.visibility ?? "private", ); useEffect(() => { - if (pipeline) setVisibility(effectiveVisibility(pipeline)); + if (app) setVisibility(effectiveVisibility(app)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const toggleVisibility = () => { - if (!pipeline) return; + if (!app) return; const next: PipelineVisibility = visibility === "public" ? "private" : "public"; - setPipelineVisibility(pipeline.id, next); + setPipelineVisibility(app.id, next); setVisibility(next); }; // Runs filtered to this app — drives both the Runs panel and the count chip. - // `model` may be undefined (404 path below); run the hook unconditionally. + // `app` may be undefined (404 path below); run the hook unconditionally. const filteredRuns = useMemo(() => { - if (!model) return []; + if (!app) return []; return MOCK_RECENT_REQUESTS.filter((r) => - modelMatchesRow(model.id, r.model), + modelMatchesRow(app.id, r.model), ); - }, [model]); + }, [app]); // Tab set: Overview + the consumer tabs are shown to everyone (Overview // renders from the catalog model, so it's there for any app); owners @@ -663,7 +657,7 @@ export default function AppDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, isOwner]); - if (!model) { + if (!app) { return (
@@ -679,40 +673,41 @@ export default function AppDetailPage() { ); } - const Icon = getAppIcon(model.category); + const deployment = app.deployment; + const Icon = getAppIcon(app.category); // One status indicator: deploy state for owners, runtime liveness for // consumers. One type indicator: Live (streaming) vs Batch (request/response). const statusTone = - isOwner && pipeline - ? pipeline.status === "deployed" + isOwner && deployment + ? deployment.status === "deployed" ? "green" - : pipeline.status === "error" + : deployment.status === "error" ? "red" - : pipeline.status === "building" + : deployment.status === "building" ? "amber" : "blue" - : model.status === "hot" + : app.status === "hot" ? "warm" : "blue"; const statusLabel = - isOwner && pipeline - ? pipeline.status === "deployed" + isOwner && deployment + ? deployment.status === "deployed" ? "Deployed" - : pipeline.status === "building" + : deployment.status === "building" ? "Building" - : pipeline.status === "error" + : deployment.status === "error" ? "Error" : "Stopped" - : model.status === "hot" + : app.status === "hot" ? "warm" : "cold"; const statusStatic = - isOwner && pipeline - ? pipeline.status !== "deployed" - : model.status !== "hot"; + isOwner && deployment + ? deployment.status !== "deployed" + : app.status !== "hot"; const isLive = - isOwner && pipeline ? pipeline.kind === "live" : Boolean(model.realtime); + isOwner && deployment ? deployment.kind === "live" : Boolean(app.realtime); return (
@@ -725,15 +720,15 @@ export default function AppDetailPage() { aria-label="Breadcrumb" > - {model.provider} + {app.provider} - {model.name} + {app.name}
@@ -745,9 +740,9 @@ export default function AppDetailPage() {
{/* Thumbnail — cover image, or a bordered icon tile fallback. */}
- {model.coverImage ? ( + {app.coverImage ? ( @@ -769,7 +764,7 @@ export default function AppDetailPage() {

- {model.name} + {app.name}

@@ -811,7 +806,7 @@ export default function AppDetailPage() {

- {model.description} + {app.description}

@@ -903,22 +898,20 @@ export default function AppDetailPage() { only) reuse the deployment-console chrome verbatim. Overview renders from the catalog model, so it works for any app; the deployment manifest, when present, enriches it. */} - {activeTab === "overview" && ( - - )} - {activeTab === "settings" && pipeline && ( + {activeTab === "overview" && } + {activeTab === "settings" && app.deployment && ( )} - {activeTab === "playground" && } - {activeTab === "api" && } - {activeTab === "readme" && } - {activeTab === "stats" && } + {activeTab === "playground" && } + {activeTab === "api" && } + {activeTab === "readme" && } + {activeTab === "stats" && } {activeTab === "jobs" && ( - + )}
diff --git a/components/dashboard/AppDetailView.tsx b/components/dashboard/AppDetailView.tsx index eba9978..f990787 100644 --- a/components/dashboard/AppDetailView.tsx +++ b/components/dashboard/AppDetailView.tsx @@ -23,12 +23,11 @@ import KpiStrip from "@/components/dashboard/KpiStrip"; import KpiCard from "@/components/dashboard/KpiCard"; import CopyButton from "@/components/dashboard/CopyButton"; import { - getPipelineById, + getAppById, effectiveVisibility, setPipelineVisibility, deploymentsForPipeline, getEnvironmentById, - pipelineToExploreApp, } from "@/lib/dashboard/mock-data"; import { formatPrice } from "@/lib/dashboard/utils"; import type { @@ -122,24 +121,18 @@ function Card({ /** * OverviewTab — the app's front page, shown to anyone (consumer or owner). * - * It renders from the catalog `model` (App), which exists for every app, so it - * works for the public apps a consumer browses from Explore — not just the - * org's own deployments. The deployment manifest (`pipeline`) is optional: when - * present (the org's deployment-backed apps) it adds the Deployment + Schema - * cards and the per-route endpoint list; when absent (a public catalog app) a - * lighter Details card stands in so the tab is still useful. + * It renders from the catalog fields on `app` (which exist for every app, so it + * works for the public apps a consumer browses from Explore) and enriches with + * the deployment manifest (`app.deployment`) when present — the Deployment + + * Schema cards and the per-route endpoint list. Since unification every app + * carries a deployment, but the `app.deployment` guards remain for safety. */ -export function OverviewTab({ - model, - pipeline, -}: { - model: App; - pipeline?: Pipeline; -}) { +export function OverviewTab({ app }: { app: App }) { + const deployment = app.deployment; // Call endpoint — the deployed pipelineId when we have it, else the catalog // slug. Live apps stream over a websocket; batch apps are request/response. - const slug = pipeline?.pipelineId ?? model.id; - const isLive = pipeline ? pipeline.kind === "live" : Boolean(model.realtime); + const slug = deployment?.pipelineId ?? app.id; + const isLive = deployment ? deployment.kind === "live" : Boolean(app.realtime); const baseUrl = isLive ? `wss://api.livepeer.org/live/${slug}` : `https://api.livepeer.org/run/${slug}`; @@ -147,11 +140,11 @@ export function OverviewTab({ // The request schema is derived from the deployment manifest, so it's only // available for deployment-backed apps. const schema = useMemo(() => { - if (!pipeline) return null; - if (pipeline.kind === "live") { + if (!deployment) return null; + if (deployment.kind === "live") { return JSON.stringify( { - pipeline_id: pipeline.pipelineId, + pipeline_id: deployment.pipelineId, transport: "trickle", channels: ["video", "events", "data"], params: { type: "object", additionalProperties: true }, @@ -162,76 +155,76 @@ export function OverviewTab({ } return JSON.stringify( { - pipeline_id: pipeline.pipelineId, + pipeline_id: deployment.pipelineId, input: { type: "object", required: ["input"] }, output: { type: "object" }, - streaming: pipeline.name.includes("SSE"), + streaming: app.name.includes("SSE"), }, null, 2, ); - }, [pipeline]); + }, [deployment, app.name]); return (
- {/* Headline stats — from the catalog model, available for every app. + {/* Headline stats — from the catalog fields, available for every app. Warm-orchestrator count is a liveness/capacity signal for the caller. */} - + 0 ? String(model.latency) : "—"} - unit={model.latency > 0 ? "ms" : undefined} + value={app.latency > 0 ? String(app.latency) : "—"} + unit={app.latency > 0 ? "ms" : undefined} /> - + - {pipeline ? ( + {deployment ? ( - {pipeline.pipelineId} + {deployment.pipelineId} - {pipeline.entrypoint} + {deployment.entrypoint} - {pipeline.image} + {deployment.image} - {pipeline.version} + {deployment.version} - {pipeline.gpu ? ( - {pipeline.gpu} + {deployment.gpu ? ( + {deployment.gpu} ) : ( CPU )} - {formatDeployed(pipeline.lastDeployedAt)} + {formatDeployed(deployment.lastDeployedAt)} - {pipeline.createdBy.name} + {deployment.createdBy.name} - {deploymentsForPipeline(pipeline.pipelineId).map((d) => { - const env = getEnvironmentById(d.environmentId); + {deploymentsForPipeline(deployment.pipelineId).map((d) => { + const env = getEnvironmentById(d.deployment!.environmentId); const dotColor = env?.kind === "production" ? "var(--color-green-bright)" @@ -246,7 +239,7 @@ export function OverviewTab({ style={{ background: dotColor }} aria-hidden="true" /> - {env?.name ?? d.environmentId} + {env?.name ?? d.deployment!.environmentId} ); })} @@ -255,15 +248,15 @@ export function OverviewTab({ ) : ( - {model.provider} + {app.provider} - {model.category} + {app.category} {isLive ? "Live · streaming" : "Batch · request/response"} - {formatPrice(model)} + {formatPrice(app)} )} @@ -277,9 +270,9 @@ export function OverviewTab({
- {pipeline && ( + {deployment && (
- {pipeline.endpoints.map((e) => ( + {deployment.endpoints.map((e) => (