From 73e71ace12d5f27befd72a2300fe1ca43d5d4b54 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Wed, 3 Jun 2026 10:51:57 -0400 Subject: [PATCH 01/10] feat(dashboard): rework IA around apps, environments, and calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolve the developer dashboard's information architecture to serve both the consumer (calls inference apps) and operator (deploys apps) without splitting the UI by persona — the organizing axis is the workspace's relationship to the network, framed by call direction. Vocabulary (grounded in the Runner SDK + gateway): - model/capability → "app" (a deployed pipeline; capability stays a network/protocol concept only) - Workspace → Organization (with public /orgs/[slug] profiles) - runs/jobs → "calls" (the SDK uses request/session; "calls" is the consumer-facing umbrella over batch + live) Routes & nav: - models/[id] → apps/[id] (ownership-gated tabs folded in) - /jobs → /calls (standalone Calls log; removed stale "jobs" naming) - Environment (Production/Development) as a per-page facet + switcher - sidebar rebuilt into home / network / environment / organization zones Home — "mission control" console: - command bar (org readout + greeting + adaptive attention line that names the most urgent thing, e.g. an erroring app) - two even-height panels: Deployed apps (what you serve) and Usage (what you consume, incl. spend on apps you didn't deploy) - Recent activity = the workspace's own calls (preview of /calls) Calls view: - Batch / Live segmented filter; metric column adapts (latency for batch, session duration for live, "Elapsed" when mixed) - live, in-progress sessions render a pulsing dot + ticking elapsed and sort to the top (new "active" AccountActivityStatus) All surfaces remain mock-data-only; typecheck, lint (0 warnings), and production build pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/{models => apps}/[id]/page.tsx | 499 ++++++----- app/(app)/apps/page.tsx | 17 + app/(app)/{jobs => calls}/page.tsx | 14 +- app/(app)/home/page.tsx | 518 ++---------- app/(app)/keys/page.tsx | 4 +- app/(app)/layout.tsx | 23 +- app/(app)/network/page.tsx | 4 +- app/(app)/orgs/[slug]/page.tsx | 84 ++ app/(app)/page.tsx | 110 ++- app/(app)/settings/page.tsx | 21 +- app/(app)/usage/page.tsx | 25 +- app/globals.css | 17 + app/layout.tsx | 2 +- app/robots.ts | 2 +- components/dashboard/ActivityPanel.tsx | 124 +++ .../dashboard/{ModelCard.tsx => AppCard.tsx} | 34 +- components/dashboard/AppDetailView.tsx | 778 ++++++++++++++++++ components/dashboard/AppsHealthPanel.tsx | 224 +++++ components/dashboard/AppsView.tsx | 327 ++++++++ .../{JobsTable.tsx => CallsTable.tsx} | 80 +- components/dashboard/CallsView.tsx | 154 ++++ .../dashboard/CapabilityLeaderboardPanel.tsx | 29 +- components/dashboard/ConsumedAppsPanel.tsx | 106 +++ components/dashboard/CostTag.tsx | 2 +- components/dashboard/DashboardPageHeader.tsx | 7 + components/dashboard/DashboardSearch.tsx | 26 +- components/dashboard/DashboardSidebar.tsx | 255 +++--- components/dashboard/EnvTag.tsx | 28 + components/dashboard/EnvironmentContext.tsx | 104 +++ components/dashboard/EnvironmentFilter.tsx | 123 +++ components/dashboard/EnvironmentSwitcher.tsx | 237 ++++++ components/dashboard/FirstRunChecklist.tsx | 351 +++----- components/dashboard/HomeCommandBar.tsx | 174 ++++ components/dashboard/HomeNudge.tsx | 12 +- components/dashboard/JobsView.tsx | 134 --- components/dashboard/KeyboardShortcuts.tsx | 6 +- components/dashboard/KeysView.tsx | 82 +- components/dashboard/LogsView.tsx | 274 ++++++ components/dashboard/NavLink.tsx | 2 +- ...WorkspaceMenu.tsx => OrganizationMenu.tsx} | 44 +- components/dashboard/ScopeChip.tsx | 60 ++ components/dashboard/SignInWall.tsx | 50 +- components/dashboard/StarButton.tsx | 8 +- components/dashboard/StatusDot.tsx | 2 +- components/dashboard/UsageView.tsx | 78 +- .../dashboard/playground/CodeSnippets.tsx | 6 +- .../dashboard/playground/PlaygroundOutput.tsx | 16 +- .../dashboard/playground/WebcamPlayground.tsx | 4 +- .../dashboard/settings/BillingSection.tsx | 2 +- .../settings/DeployTokensSection.tsx | 166 ++++ .../dashboard/settings/GeneralSection.tsx | 24 +- .../dashboard/settings/LimitsSection.tsx | 10 +- .../dashboard/settings/MembersSection.tsx | 4 +- .../settings/NotificationsSection.tsx | 2 +- .../dashboard/settings/SettingsPrimitives.tsx | 4 +- .../dashboard/statistics/OverviewTab.tsx | 14 +- .../dashboard/statistics/UtilizationTab.tsx | 2 +- .../{ModelAnalytics.tsx => AppAnalytics.tsx} | 6 +- components/dashboard/useTickWhileActive.ts | 18 + lib/constants.ts | 31 +- .../{model-stats.ts => app-stats.ts} | 10 +- lib/dashboard/mock-data.ts | 577 ++++++++++++- lib/dashboard/org-consumption.ts | 65 ++ lib/dashboard/org-fleet.ts | 49 ++ lib/dashboard/types.ts | 109 ++- ...{useStarredModels.ts => useStarredApps.ts} | 2 +- lib/dashboard/utils.ts | 59 +- next.config.ts | 26 +- 68 files changed, 4889 insertions(+), 1572 deletions(-) rename app/(app)/{models => apps}/[id]/page.tsx (64%) create mode 100644 app/(app)/apps/page.tsx rename app/(app)/{jobs => calls}/page.tsx (59%) create mode 100644 app/(app)/orgs/[slug]/page.tsx create mode 100644 components/dashboard/ActivityPanel.tsx rename components/dashboard/{ModelCard.tsx => AppCard.tsx} (84%) create mode 100644 components/dashboard/AppDetailView.tsx create mode 100644 components/dashboard/AppsHealthPanel.tsx create mode 100644 components/dashboard/AppsView.tsx rename components/dashboard/{JobsTable.tsx => CallsTable.tsx} (58%) create mode 100644 components/dashboard/CallsView.tsx create mode 100644 components/dashboard/ConsumedAppsPanel.tsx create mode 100644 components/dashboard/EnvTag.tsx create mode 100644 components/dashboard/EnvironmentContext.tsx create mode 100644 components/dashboard/EnvironmentFilter.tsx create mode 100644 components/dashboard/EnvironmentSwitcher.tsx create mode 100644 components/dashboard/HomeCommandBar.tsx delete mode 100644 components/dashboard/JobsView.tsx create mode 100644 components/dashboard/LogsView.tsx rename components/dashboard/{WorkspaceMenu.tsx => OrganizationMenu.tsx} (88%) create mode 100644 components/dashboard/ScopeChip.tsx create mode 100644 components/dashboard/settings/DeployTokensSection.tsx rename components/dashboard/stats/{ModelAnalytics.tsx => AppAnalytics.tsx} (98%) create mode 100644 components/dashboard/useTickWhileActive.ts rename lib/dashboard/{model-stats.ts => app-stats.ts} (96%) create mode 100644 lib/dashboard/org-consumption.ts create mode 100644 lib/dashboard/org-fleet.ts rename lib/dashboard/{useStarredModels.ts => useStarredApps.ts} (97%) diff --git a/app/(app)/models/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx similarity index 64% rename from app/(app)/models/[id]/page.tsx rename to app/(app)/apps/[id]/page.tsx index f577dc3..a9d51f5 100644 --- a/app/(app)/models/[id]/page.tsx +++ b/app/(app)/apps/[id]/page.tsx @@ -1,47 +1,50 @@ "use client"; import { useState, useCallback, useEffect, useMemo } from "react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import Link from "next/link"; import { - Flame, - Snowflake, BarChart3, Play, Code, FileText, - Clock, - Server, RotateCcw, - Zap, - LayoutGrid, - Star, - Copy, - Check, Activity, + Box, + Radio, + ArrowUpRight, + Settings as SettingsIcon, } from "lucide-react"; import { useAuth } from "@/components/dashboard/AuthContext"; import DashboardSubNav from "@/components/dashboard/DashboardSubNav"; import CostTag from "@/components/dashboard/CostTag"; import KeyBadge from "@/components/dashboard/KeyBadge"; -import JobsTable from "@/components/dashboard/JobsTable"; +import CallsTable from "@/components/dashboard/CallsTable"; import StatusDot from "@/components/dashboard/StatusDot"; -import Tooltip from "@/components/design-system/Tooltip"; -import { useStarredModels } from "@/lib/dashboard/useStarredModels"; import { - getModelById, + getCapabilityById, + getPipelineById, + pipelineToExploreApp, + effectiveVisibility, + setPipelineVisibility, + organizationSlug, + PIPELINE_APP_IDS, SETTINGS_API_KEYS, MOCK_RECENT_REQUESTS, } from "@/lib/dashboard/mock-data"; -import { getModelIcon, formatRuns, formatPrice } from "@/lib/dashboard/utils"; +import { getAppIcon } from "@/lib/dashboard/utils"; import PlaygroundForm from "@/components/dashboard/playground/PlaygroundForm"; import JsonInput from "@/components/dashboard/playground/JsonInput"; import PlaygroundOutput from "@/components/dashboard/playground/PlaygroundOutput"; import TranscodingOutput from "@/components/dashboard/playground/TranscodingOutput"; import CodeSnippets from "@/components/dashboard/playground/CodeSnippets"; import WebcamPlayground from "@/components/dashboard/playground/WebcamPlayground"; -import ModelAnalytics from "@/components/dashboard/stats/ModelAnalytics"; -import type { Model } from "@/lib/dashboard/types"; +import AppAnalytics from "@/components/dashboard/stats/AppAnalytics"; +import { + OverviewTab, + SettingsTab, +} from "@/components/dashboard/AppDetailView"; +import type { App, PipelineVisibility } from "@/lib/dashboard/types"; // ─── Tabs ─── // @@ -50,7 +53,18 @@ import type { Model } from "@/lib/dashboard/types"; // specific model so the badge tracks reality (zero for empty, drops the chip // entirely so we don't show "Jobs (0)"). -type Tab = "playground" | "api" | "readme" | "stats" | "jobs"; +// The consumer tabs are visible to everyone; the owner of the app additionally +// gets Overview (the deployment console chrome), Logs, and Settings — same page, +// extra tabs, gated by ownership. This is the GitHub model (everyone sees the +// repo; owners also see Settings). +type Tab = + | "overview" + | "playground" + | "api" + | "readme" + | "stats" + | "jobs" + | "settings"; type TabSpec = { key: Tab; @@ -64,7 +78,17 @@ const TABS: TabSpec[] = [ { key: "api", label: "API", icon: Code }, { key: "readme", label: "README", icon: FileText }, { key: "stats", label: "Stats", icon: BarChart3 }, - { key: "jobs", label: "Jobs", icon: Activity }, + { key: "jobs", label: "Logs", icon: Activity }, +]; + +// Owner-only tabs, bracketing the consumer set: Overview leads (the deployment +// console), Settings trails. (Runs is the single activity view — per-app raw +// log-tailing lives on the Apps-list Logs view, filterable to one app.) +const OWNER_LEAD_TABS: TabSpec[] = [ + { key: "overview", label: "Overview", icon: Box }, +]; +const OWNER_TRAIL_TABS: TabSpec[] = [ + { key: "settings", label: "Settings", icon: SettingsIcon }, ]; // Match a model's catalog id (e.g. "flux-schnell") against an activity row's @@ -82,7 +106,7 @@ function modelMatchesRow(catalogId: string, runModel: string): boolean { // ─── Playground Tab ─── -function PlaygroundTab({ model }: { model: Model }) { +function PlaygroundTab({ model }: { model: App }) { const [inputMode, setInputMode] = useState<"form" | "json" | "python" | "node" | "http">("form"); const [isRunning, setIsRunning] = useState(false); const [result, setResult] = useState(null); @@ -149,7 +173,7 @@ function PlaygroundTab({ model }: { model: Model }) {

- Playground not available for this model + Playground not available for this app

); @@ -287,7 +311,7 @@ function PlaygroundTab({ model }: { model: Model }) { // ─── API Tab ─── -function ApiTab({ model }: { model: Model }) { +function ApiTab({ model }: { model: App }) { const baseUrl = model.apiEndpoint ?? "https://gateway.livepeer.org/v1"; const endpoint = model.category === "Language" @@ -357,7 +381,7 @@ function ApiTab({ model }: { model: Model }) { // ─── README Tab ─── -function ReadmeTab({ model }: { model: Model }) { +function ReadmeTab({ model }: { model: App }) { if (!model.readme) { return (
@@ -530,23 +554,21 @@ function ReadmeTab({ model }: { model: Model }) { // ─── Stats Tab ─── -function StatsTab({ model }: { model: Model }) { - return ; +function StatsTab({ model }: { model: App }) { + return ; } // ─── Jobs Tab ─── // -// Reuses the shared `JobsTable` so this surface, the home "Recent jobs" panel, -// and the standalone `/jobs` view all render identical rows. Empty -// state is bespoke here because the message ("No jobs yet for {model.name}") -// is capability-specific and doesn't make sense to push into the shared -// component. +// Reuses the shared `CallsTable` so this surface and the standalone `/calls` +// view render identical rows. Empty state is bespoke here because the message +// is app-specific and doesn't make sense to push into the shared component. function JobsTab({ model, runs, }: { - model: Model; + model: App; runs: import("@/lib/dashboard/types").AccountActivityRow[]; }) { if (runs.length === 0) { @@ -554,141 +576,52 @@ function JobsTab({

- No jobs yet for {model.name} + No logs yet for {model.name}

- Calls to this capability from your workspace will show up here. + Calls to this app from your organization will show up here.

); } - return ; -} - -// ─── Chrome bar (44px) — multi-segment breadcrumb + Pin + auth CTAs ───────── -// -// Mirrors the Livepeer Dashboard v3 `PageHead` for the model detail route. -// First crumb has the grid icon + "Explore", last crumb is the model name in -// white. Right side carries `Pin` (toggles Star). When the visitor is signed -// out, a `Sign in` / `Sign up` pair is appended after a vertical divider — -// same pattern as `DashboardPageHeader`, so an unauthenticated user landing -// here from a shared model URL has the auth path one click away. - -function ModelChromeBar({ model }: { model: Model }) { - const { isStarred, toggleStar } = useStarredModels(); - const pinned = isStarred(model.id); - const { isConnected, isLoading } = useAuth(); - const pathname = usePathname() ?? ""; - const isAuthRoute = - pathname.startsWith("/login") || - pathname.startsWith("/signup"); - // Hide auth CTAs while auth state is still resolving (one frame on first - // paint) to avoid flashing them in for connected users. - const showAuthCTAs = !isLoading && !isConnected && !isAuthRoute; - - return ( -
- -
- ); -} - -// ─── Model ID chip — bordered chip with copy-on-click + mono id ───────────── - -function ModelIdChip({ modelId }: { modelId: string }) { - const [copied, setCopied] = useState(false); - const onCopy = () => { - navigator.clipboard?.writeText(modelId).catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 1400); - }; - return ( - - ); + return ; } // ─── Main Page ─── -export default function ModelDetailPage() { +export default function AppDetailPage() { const { id } = useParams<{ id: string }>(); - const [activeTab, setActiveTab] = useState("playground"); - const model = getModelById(id); + 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); + const isOwner = 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", + ); + useEffect(() => { + if (pipeline) setVisibility(effectiveVisibility(pipeline)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + const toggleVisibility = () => { + if (!pipeline) return; + const next: PipelineVisibility = + visibility === "public" ? "private" : "public"; + setPipelineVisibility(pipeline.id, next); + setVisibility(next); + }; - // Jobs filtered to this model — drives both the Jobs panel and the count - // chip on the Jobs tab. `model` may be undefined here (404 path below); we - // run the hook unconditionally with a stable input to keep hook order intact. + // 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. const filteredRuns = useMemo(() => { if (!model) return []; return MOCK_RECENT_REQUESTS.filter((r) => @@ -696,20 +629,38 @@ export default function ModelDetailPage() { ); }, [model]); - // Tabs spec is rebuilt per-render so the Jobs count tracks the filtered set. - const tabs: TabSpec[] = useMemo( - () => - TABS.map((t) => - t.key === "jobs" ? { ...t, count: filteredRuns.length } : t, - ), - [filteredRuns.length], - ); + // Tab set: consumer tabs for everyone; owners get Overview (lead) + Logs & + // Settings (trail) — same template, ownership just unlocks more tabs. + const tabs: TabSpec[] = useMemo(() => { + const consumer = TABS.map((t) => + t.key === "jobs" ? { ...t, count: filteredRuns.length } : t, + ); + return isOwner + ? [...OWNER_LEAD_TABS, ...consumer, ...OWNER_TRAIL_TABS] + : consumer; + }, [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"; + const [activeTab, setActiveTab] = useState(defaultTab); + useEffect(() => { + const requested = new URLSearchParams(window.location.search).get( + "tab", + ) as Tab | null; + setActiveTab( + requested && tabs.some((t) => t.key === requested) + ? requested + : defaultTab, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, isOwner]); if (!model) { return (
-

Model not found

+

App not found

- {/* Chrome bar — full multi-segment breadcrumb on the left, - Pin + Docs actions on the right. Per the Livepeer Dashboard v3 - design (`PageHead` with `crumbs={[{ icon: 'grid', label: 'Explore' }, ...]}`). */} - + {/* Navigation header — full-width bar carrying the organization / app + breadcrumb (the app's owning organization for owners, the publisher for + consumers). */} +
+ +
- {/* mdv2-head — thumbnail + eyebrow + title + desc, ID chip on the right */} -
- {/* Thumbnail uses the model's coverImage when available; falls back - to a bordered icon tile that matches the v3 design glow. */} -
+ {/* Identity row — thumbnail · name · status · type · visibility · + Open playground. Identical for every app detail view; dense + metrics live in the Overview / Stats tabs. */} +
+ {/* Thumbnail — cover image, or a bordered icon tile fallback. */} +
{model.coverImage ? ( ) : ( )}
-
-

- {model.provider} -

-
-

+
+
+

{model.name}

- {model.precision && ( - - {model.precision} - - )} + + + {statusLabel} + + + {isLive ? ( + + {isOwner && + (visibility === "public" ? ( + + Public +
-

+

{model.description}

- - {/* ID chip — bordered, with copy icon and the model id in mono. - Replaces the previous Star+Copy split since `Pin` now lives in - the chrome bar above. */} - -

- - {/* mdv2-strip — single bordered metadata row with right-aligned Run sample CTA */} -
- {model.status === "hot" ? ( - - - warm - - ) : ( - - - cold - - )} - {model.realtime && ( - - - - realtime - - - )} - - {model.category} - - -
{/* Tabs — flush document-style underline (mdv2-tabs) */}
{ const i = tabs.findIndex((t) => t.key === activeTab); @@ -930,7 +878,7 @@ export default function ModelDetailPage() { {/* Tabs — mobile scroll strip */} setActiveTab(key as Tab)} @@ -944,6 +892,17 @@ export default function ModelDetailPage() { id={`tabpanel-${activeTab}`} aria-labelledby={`tab-${activeTab}`} > + {/* Owner-only tabs reuse the operator console chrome verbatim. */} + {activeTab === "overview" && pipeline && ( + + )} + {activeTab === "settings" && pipeline && ( + + )} {activeTab === "playground" && } {activeTab === "api" && } {activeTab === "readme" && } diff --git a/app/(app)/apps/page.tsx b/app/(app)/apps/page.tsx new file mode 100644 index 0000000..0755c1e --- /dev/null +++ b/app/(app)/apps/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import AppsView from "@/components/dashboard/AppsView"; +import SignInWall from "@/components/dashboard/SignInWall"; +import { useAuth } from "@/components/dashboard/AuthContext"; + +export default function AppsPage() { + const { isConnected, isLoading } = useAuth(); + + if (isLoading) return null; + + // Organization-only — logged-out users see the sign-in wall instead of the + // apps list. + if (!isConnected) return ; + + return ; +} diff --git a/app/(app)/jobs/page.tsx b/app/(app)/calls/page.tsx similarity index 59% rename from app/(app)/jobs/page.tsx rename to app/(app)/calls/page.tsx index 45e363f..594f4cc 100644 --- a/app/(app)/jobs/page.tsx +++ b/app/(app)/calls/page.tsx @@ -1,24 +1,24 @@ "use client"; -import JobsView from "@/components/dashboard/JobsView"; +import CallsView from "@/components/dashboard/CallsView"; import SignInWall from "@/components/dashboard/SignInWall"; import { useAuth } from "@/components/dashboard/AuthContext"; // Note: page metadata isn't valid in client components, so the previous // `metadata` export moves out alongside this auth gate. Title/description for -// the jobs route now come from the parent layout's defaults; if we want +// the calls route now come from the parent layout's defaults; if we want // per-route SEO back, we'll need to split the wall + content into a server // component shell that owns metadata and a client component that owns auth. -export default function JobsPage() { +export default function CallsPage() { const { isConnected, isLoading } = useAuth(); // Avoid flashing either state while auth hydrates from localStorage. if (isLoading) return null; - // Workspace-only route — logged-out users see the route-specific sign-in - // wall ("Jobs are workspace-only") instead of an empty list. - if (!isConnected) return ; + // Organization-only route — logged-out users see the route-specific sign-in + // wall ("Calls are organization-only") instead of an empty list. + if (!isConnected) return ; - return ; + return ; } diff --git a/app/(app)/home/page.tsx b/app/(app)/home/page.tsx index 967e9b2..701e176 100644 --- a/app/(app)/home/page.tsx +++ b/app/(app)/home/page.tsx @@ -1,287 +1,21 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { - ArrowRight, - BarChart3, - ChevronDown, - House, - Activity, -} from "lucide-react"; +import { BarChart3, ChevronDown, House } from "lucide-react"; import { useAuth } from "@/components/dashboard/AuthContext"; -import { - MODELS, - MOCK_RECENT_REQUESTS, - ACCOUNT_USAGE_SUMMARY, -} from "@/lib/dashboard/mock-data"; -import { generateSparklineData, getModelIcon } from "@/lib/dashboard/utils"; -import Banner from "@/components/dashboard/Banner"; +import { getOrgFleet } from "@/lib/dashboard/org-fleet"; import DashboardPageHeader from "@/components/dashboard/DashboardPageHeader"; -import EmptyState from "@/components/dashboard/EmptyState"; import FirstRunChecklist, { FIRST_RUN_CHANGED_EVENT, FIRST_RUN_DISMISSED_KEY, } from "@/components/dashboard/FirstRunChecklist"; -import CapabilityLeaderboardPanel from "@/components/dashboard/CapabilityLeaderboardPanel"; -import KpiCard from "@/components/dashboard/KpiCard"; -import KpiStrip from "@/components/dashboard/KpiStrip"; -import JobsTable from "@/components/dashboard/JobsTable"; +import HomeCommandBar from "@/components/dashboard/HomeCommandBar"; +import AppsHealthPanel from "@/components/dashboard/AppsHealthPanel"; +import ConsumedAppsPanel from "@/components/dashboard/ConsumedAppsPanel"; +import ActivityPanel from "@/components/dashboard/ActivityPanel"; import SectionHeader from "@/components/dashboard/SectionHeader"; -import type { ModelCategory } from "@/lib/dashboard/types"; - -// ─── Mock data ─── - -function formatRelativeTime(iso: string): string { - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return ""; - const diffMs = Date.now() - then; - const minutes = Math.round(diffMs / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); - if (days === 1) return "yesterday"; - return `${days}d ago`; -} - -function formatLatency(ms: number | null): string { - if (ms == null) return "—"; - if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; - return `${ms}ms`; -} - -// ─── Helpers for the workspace home ─── -// -// (`WelcomeCard`, `FeaturedCapabilities`, `BrowseByCategory`, and -// `GettingStartedStrip` previously lived here as the signed-out body of -// /home. The v4 prototype replaces that marketing-style page with a -// route-specific `SignInWall`, so those components are gone — `git log` if -// you need them back. Browse-by-category and the marketing pitch now live on -// /, which is the public landing for logged-out users.) - -// Map a recent-request pipeline string to a category so we can pick an icon -// for runs whose model isn't in the MODELS list (mock data has /-shaped names -// like "daydream/video-v2"). Falls back to a generic activity icon. -const PIPELINE_TO_CATEGORY: Record = { - "video-to-video": "Video Editing", - "live-video-to-video": "Video Editing", - "live-transcoding": "Live Transcoding", - "text-to-image": "Image Generation", - language: "Language", - "audio-to-text": "Speech", - "video-understanding": "Video Understanding", - "text-to-speech": "Speech", - "image-to-video": "Video Generation", -}; - -// Best-effort lookup from a recent-request row to a Model in our MODELS list. -// Matches on name fragment (provider or model slug). Used for the "Run again" -// CTA target — falls back to / search if no match. -function findModelForRunRow(rowModel: string) { - const slug = rowModel.split("/").pop() ?? rowModel; - const provider = rowModel.split("/")[0] ?? ""; - return ( - MODELS.find((m) => - m.name.toLowerCase().replace(/\s+/g, "").includes(slug.toLowerCase().replace(/-/g, "")), - ) ?? - MODELS.find((m) => m.provider.toLowerCase().includes(provider.toLowerCase())) ?? - null - ); -} - -// ─── Last Run Hero — most prominent element on the workspace home ─── - -function LastRunHero() { - const last = MOCK_RECENT_REQUESTS[0]; - if (!last) return null; - - const Icon = - getModelIcon(PIPELINE_TO_CATEGORY[last.pipeline]) ?? Activity; - const matchedModel = findModelForRunRow(last.model); - const playgroundHref = matchedModel - ? `/models/${matchedModel.id}?tab=playground` - : `/?q=${encodeURIComponent(last.model.split("/").pop() ?? last.model)}`; - - const isSuccess = last.status === "success"; - - return ( -
-

- Last job · {formatRelativeTime(last.timestamp)} -

- -
-
-
-
-
-

- {last.model} - - {formatLatency(last.latencyMs)} - -

-

- - - · - {last.pipeline} - · - via {last.signerLabel} -

-
-
- -
- - Inspect - - - Open in playground - - -
-
-
- ); -} - -// ─── Pinned — recently-used + starred models, one-click re-launch ─── - -function PinnedCapabilities() { - // Build the pinned set: distinct models from recent runs (priority) + starred. - const recentNames = Array.from( - new Set(MOCK_RECENT_REQUESTS.map((r) => r.model)), - ); - const recentModels = recentNames - .map((name) => findModelForRunRow(name)) - .filter((m): m is (typeof MODELS)[number] => Boolean(m)); - - // Take up to 4 (one row on lg). - const seen = new Set(); - const pinned = recentModels - .filter((m) => { - if (seen.has(m.id)) return false; - seen.add(m.id); - return true; - }) - .slice(0, 4); - - if (pinned.length === 0) return null; - - return ( -
- - See all capabilities → - - } - className="mb-3" - /> -
    - {pinned.map((model) => { - const Icon = getModelIcon(model.category); - return ( -
  • - -
    -
    -
    -

    - {model.provider.toLowerCase().replace(/\s+/g, "-")} -

    -

    {model.name}

    -
    - -
  • - ); - })} -
-
- ); -} - -// ─── Recent jobs panel ───────────────────────────────────────────────────── -// -// Per the v5 prototype, "Recent jobs" is a panel with its own head (title + -// "Across all capabilities" sub + "View all →" action). The jobs table sits -// flush below the head sharing the same rounded border. The previous version -// rendered a free-standing `` above an already-bordered -// `JobsTable`, which made it look like two stacked panels. - -function RecentJobsPanel() { - const rows = MOCK_RECENT_REQUESTS.slice(0, 8); - - if (rows.length === 0) { - return ( - } - title="No jobs yet" - description="Run inference to see your jobs here — status, latency, and time per request." - action={{ label: "Browse capabilities", href: "/" }} - /> - ); - } - - return ( -
-
-
-

Recent jobs

-

- Across all capabilities -

-
- - View all
- {/* Shared `JobsTable` rendered borderless so the panel's outer chrome - provides the rounded edge — keeps row vocabulary identical to - /jobs and the model-detail Jobs tab. */} - -
- ); -} // ─── Home page header — chrome bar with Period selector + actions ─── @@ -313,140 +47,27 @@ function HomePageHeader() { ); } -// ─── Greeting block — eyebrow + 'Good morning, {name}' + live indicator ─── - -function Greeting({ - workspace, - firstName, -}: { - workspace: string; - firstName: string; -}) { - const hour = new Date().getHours(); - const greeting = - hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening"; - return ( -
-
-

- Workspace · {workspace} -

-

- {greeting}, {firstName} -

-
-

- Last refresh: just now · live -

-
- ); -} - -// ─── Home KPI strip — 4 stats with sparklines ─── - -function HomeKpis() { - // Stable mock sparklines per render (regenerate only on mount) - const reqSpark = useMemo(() => generateSparklineData(30), []); - const latSpark = useMemo(() => generateSparklineData(30), []); - const errSpark = useMemo(() => generateSparklineData(30), []); - const spendSpark = useMemo(() => generateSparklineData(30), []); - - return ( - - - - - - - ); -} - -// (`UsageByCapabilityPanel` and `PinnedCapabilitiesPanel` previously lived -// here as the home view's two-column bottom section. The v5 prototype -// replaces both with a single full-width sortable leaderboard — -// `CapabilityLeaderboardPanel`. `git log` if you need them back.) - -// ─── Capacity Banner — slim free-tier callout at the top of Home ─── -// -// Per the Livepeer Console design (Apr 2026): always visible, quiet, with the -// exact remaining quota and an inline accent action. No threshold gating — -// the banner is ambient context, not an alert. - -function CapacityBanner() { - const used = ACCOUNT_USAGE_SUMMARY.freeTierUsed; - const limit = ACCOUNT_USAGE_SUMMARY.freeTierLimit; - const remaining = Math.max(0, limit - used); - - return ( - - ); -} - - // ─── Home Page ─── // -// Workspace pattern (CCO rethink): the home is for getting back to work, -// not reading a stats report. Composition top-to-bottom: -// 1. Last run hero — the most likely next action is "do that again" -// 2. Pinned capabilities — recent + starred, one-click re-launch -// 3. Your runs — slim list of recent inferences -// 4. Capacity footnote — free tier as a quiet line, not a hero -// -// Stats (errors, p50, spend, 30d volume) live on /settings?tab=usage -// where they belong. The home stays focused on "your work". +// "Mission control" rethink: a workspace sits between two flows of network +// traffic — what it SERVES (inbound, apps it deployed) and what it CONSUMES +// (outbound, apps across the network, mostly ones it didn't deploy). The Home +// is organized around those two directions, not around personas. Composition: +// 1. Command bar — system readout (served + spent) + greeting + an adaptive +// attention line naming the single most urgent thing on arrival +// 2. Get started — auto-detecting onboarding, until the loop is done +// 3. Two ledgers — Deployed apps (what you serve: calls + Yours/External) +// beside Usage (what you consume: spend + Your apps/Others'). Each panel +// leads with its own directional summary; no separate hero band. +// 4. Recent activity — the workspace's own requests (what counts toward +// its usage); a live preview of /usage export default function HomePage() { const { isConnected, isLoading, user } = useAuth(); const router = useRouter(); // Signed-out users redirect to / — the public landing. - // The Home view is workspace-only (KPIs, recent runs, capability + // The Home view is organization-only (KPIs, recent runs, capability // leaderboard), and a SignInWall on the root /home URL was the // wrong default: visitors arrived at a sign-in gate before they'd had a // chance to see what's on the platform. Explore is the discovery surface @@ -462,7 +83,7 @@ export default function HomePage() { // to the playground). Quickstart in the sidebar footer clears this flag to // re-open the checklist. Mock-only gate: in production we'd ALSO check // server-side run history, but in mock mode the flag alone is the source - // of truth (MOCK_RECENT_REQUESTS is always non-empty for the workspace demo). + // of truth (MOCK_RECENT_REQUESTS is always non-empty for the organization demo). const [firstRunDismissed, setFirstRunDismissed] = useState( null, ); @@ -498,54 +119,55 @@ export default function HomePage() { setFirstRunDismissed(true); }; - // Workspace name + greeting first-name (stand-ins until real workspaces + + // Organization name + greeting first-name (stand-ins until real organizations + // proper user profile fields exist; matches the design spec). - const workspace = "Flipbook"; - const firstName = (user?.name?.split(" ")[0] ?? "there"); + const organization = "Flipbook"; + const firstName = user?.name?.split(" ")[0] ?? "there"; + + // The Fleet anchors the console split; a consumer-only org with no deployed + // apps drops to a single column so the Vitals rail carries the page. + const hasApps = getOrgFleet().count > 0; // Signed-out users are redirected to / via the useEffect // above; render nothing in the meantime (one frame max) so they don't see - // a flash of workspace-mock data before the redirect lands. + // a flash of organization-mock data before the redirect lands. if (!isConnected || !user) { return null; } // Hold off rendering signed-in content for one frame while we read the - // localStorage flag, so the workspace doesn't flash before the checklist. + // localStorage flag, so the organization doesn't flash before the checklist. if (firstRunDismissed === null) { return
; } - // Signed-in workspace home — Linear / Livepeer Console structure: - // chrome bar → greeting → KPIs → free-tier banner → quickstart (when not - // dismissed) → recent runs → usage chart + pinned capabilities grid. + // Signed-in operations console — see the composition note above HomePage. return ( -
+
+ {/* Atmosphere — a faint brand-green aura bleeding from the top edge, so + the console reads as a lit panel rather than a flat page. */} +