diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx index e9ac14c..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 ─── // @@ -73,9 +71,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[] = [ @@ -594,59 +593,57 @@ 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 (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( @@ -660,7 +657,7 @@ export default function AppDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, isOwner]); - if (!model) { + if (!app) { return (
@@ -676,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 (
@@ -722,15 +720,15 @@ export default function AppDetailPage() { aria-label="Breadcrumb" > - {model.provider} + {app.provider} - {model.name} + {app.name}
@@ -742,9 +740,9 @@ export default function AppDetailPage() {
{/* Thumbnail — cover image, or a bordered icon tile fallback. */}
- {model.coverImage ? ( + {app.coverImage ? ( @@ -766,7 +764,7 @@ export default function AppDetailPage() {

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

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

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

@@ -897,23 +895,23 @@ 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 && ( - - )} - {activeTab === "settings" && 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" && 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 4ba797e..f990787 100644 --- a/components/dashboard/AppDetailView.tsx +++ b/components/dashboard/AppDetailView.tsx @@ -23,13 +23,15 @@ 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, } from "@/lib/dashboard/mock-data"; +import { formatPrice } from "@/lib/dashboard/utils"; import type { + App, Pipeline, PipelineStatusKind, PipelineVisibility, @@ -116,17 +118,33 @@ function Card({ // ── Overview ────────────────────────────────────────────────────────────────── -export function OverviewTab({ app }: { app: Pipeline }) { - const baseUrl = - app.kind === "live" - ? `wss://api.livepeer.org/live/${app.pipelineId}` - : `https://api.livepeer.org/run/${app.pipelineId}`; - +/** + * OverviewTab — the app's front page, shown to anyone (consumer or owner). + * + * 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({ 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 = 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}`; + + // The request schema is derived from the deployment manifest, so it's only + // available for deployment-backed apps. const schema = useMemo(() => { - if (app.kind === "live") { + if (!deployment) return null; + if (deployment.kind === "live") { return JSON.stringify( { - pipeline_id: app.pipelineId, + pipeline_id: deployment.pipelineId, transport: "trickle", channels: ["video", "events", "data"], params: { type: "object", additionalProperties: true }, @@ -137,7 +155,7 @@ export function OverviewTab({ app }: { app: Pipeline }) { } return JSON.stringify( { - pipeline_id: app.pipelineId, + pipeline_id: deployment.pipelineId, input: { type: "object", required: ["input"] }, output: { type: "object" }, streaming: app.name.includes("SSE"), @@ -145,94 +163,106 @@ export function OverviewTab({ app }: { app: Pipeline }) { null, 2, ); - }, [app]); + }, [deployment, app.name]); return (
+ {/* Headline stats — from the catalog fields, 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(app.latency) : "—"} + unit={app.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 ( - + + + {deployment.version} + + + {deployment.gpu ? ( + {deployment.gpu} + ) : ( + CPU + )} + + + {formatDeployed(deployment.lastDeployedAt)} + + + + + {deployment.createdBy.name} + + + + + {deploymentsForPipeline(deployment.pipelineId).map((d) => { + const env = getEnvironmentById(d.deployment!.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" + > + + ); + })} + + + + ) : ( + + {app.provider} + + {app.category} + + + {isLive ? "Live · streaming" : "Batch · request/response"} + + + {formatPrice(app)} + + + )} -
+
{baseUrl} @@ -240,35 +270,39 @@ export function OverviewTab({ app }: { app: Pipeline }) {
-
- {app.endpoints.map((e) => ( -
- - {e.method} - - - {e.path} - - {e.description && ( - - {e.description} + {deployment && ( +
+ {deployment.endpoints.map((e) => ( +
+ + {e.method} - )} -
- ))} -
+ + {e.path} + + {e.description && ( + + {e.description} + + )} +
+ ))} +
+ )} - -
-
-            {schema}
-          
-
-
+ {schema && ( + +
+
+              {schema}
+            
+
+
+ )}
); } @@ -278,16 +312,17 @@ export function OverviewTab({ app }: { app: Pipeline }) { type LogLine = { t: string; level: "info" | "warn" | "error"; msg: string }; function buildLogs(app: Pipeline): LogLine[] { - if (app.status === "building") { + const deployment = app.deployment; + if (deployment.status === "building") { return [ - { t: "08:55:02", level: "info", msg: `Building image for ${app.pipelineId}…` }, + { t: "08:55:02", level: "info", msg: `Building image for ${deployment.pipelineId}…` }, { t: "08:55:04", level: "info", msg: "Resolving python packages" }, { t: "08:55:39", level: "info", msg: "Running prepare step (caching weights)" }, { t: "08:56:10", level: "info", msg: "Pushing layers to registry" }, { t: "08:56:22", level: "warn", msg: "Image is large (4.2 GB) — first cold start may be slow" }, ]; } - if (app.status === "error") { + if (deployment.status === "error") { return [ { t: "22:01:00", level: "info", msg: "Session started — events channel open" }, { t: "22:01:00", level: "info", msg: `setup() complete · model=whisper-tiny.en` }, @@ -296,7 +331,7 @@ function buildLogs(app: Pipeline): LogLine[] { { t: "22:01:09", level: "error", msg: "5 records emitted → 3 delivered" }, ]; } - if (app.kind === "live") { + if (deployment.kind === "live") { return [ { t: "17:30:00", level: "info", msg: "Session started — allocating video track" }, { t: "17:30:00", level: "info", msg: "heartbeat task started" }, @@ -306,7 +341,7 @@ function buildLogs(app: Pipeline): LogLine[] { ]; } return [ - { t: "14:09:00", level: "info", msg: `POST /predict · 200 · ${app.p50LatencyMs}ms` }, + { t: "14:09:00", level: "info", msg: `POST /predict · 200 · ${deployment.p50LatencyMs}ms` }, { t: "14:09:01", level: "info", msg: "POST /predict · 200 · 71ms" }, { t: "14:09:02", level: "info", msg: "POST /predict · 200 · 59ms" }, { t: "14:09:03", level: "warn", msg: "POST /predict · 200 · 184ms (slow — orchestrator cold)" }, @@ -328,7 +363,7 @@ export function LogsTab({ app }: { app: Pipeline }) {