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..7f18e2e 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"; +// Everyone sees the consumer tabs (Overview, Playground, API, README, Stats, +// Logs); the owner additionally gets Settings — same page, one extra tab 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; @@ -59,12 +73,22 @@ 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. +const OVERVIEW_TAB: TabSpec = { key: "overview", label: "Overview", icon: Box }; + const TABS: TabSpec[] = [ { key: "playground", label: "Playground", icon: Play }, { 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 }, +]; + +// The one ownership-gated tab — the deploy/publish controls trail the set. +const OWNER_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,22 +629,39 @@ 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: Overview (when deployment-backed) + the consumer tabs are shown to + // everyone; 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; + return isOwner ? [...base, ...OWNER_TABS] : base; + }, [filteredRuns.length, isOwner, pipeline]); + + // 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

Back to Explore @@ -721,22 +671,72 @@ export default function ModelDetailPage() { ); } - const Icon = getModelIcon(model.category); + const Icon = getAppIcon(model.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" + ? "green" + : pipeline.status === "error" + ? "red" + : pipeline.status === "building" + ? "amber" + : "blue" + : model.status === "hot" + ? "warm" + : "blue"; + const statusLabel = + isOwner && pipeline + ? pipeline.status === "deployed" + ? "Deployed" + : pipeline.status === "building" + ? "Building" + : pipeline.status === "error" + ? "Error" + : "Stopped" + : model.status === "hot" + ? "warm" + : "cold"; + const statusStatic = + isOwner && pipeline + ? pipeline.status !== "deployed" + : model.status !== "hot"; + const isLive = + isOwner && pipeline ? pipeline.kind === "live" : Boolean(model.realtime); return (
- {/* 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 +877,7 @@ export default function ModelDetailPage() { {/* Tabs — mobile scroll strip */} setActiveTab(key as Tab)} @@ -944,6 +891,18 @@ export default function ModelDetailPage() { id={`tabpanel-${activeTab}`} 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 && ( + + )} {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)/explore/page.tsx b/app/(app)/explore/page.tsx new file mode 100644 index 0000000..bdef98a --- /dev/null +++ b/app/(app)/explore/page.tsx @@ -0,0 +1,8 @@ +import ExploreView from "@/components/dashboard/ExploreView"; + +// /explore — the app catalog as a first-class destination (the sidebar's +// "Explore" item points here). Reachable signed-in or out; unlike `/`, it never +// redirects, so logged-in users can browse without bouncing to Home. +export default function ExplorePage() { + return ; +} diff --git a/app/(app)/home/page.tsx b/app/(app)/home/page.tsx index 967e9b2..cf2039e 100644 --- a/app/(app)/home/page.tsx +++ b/app/(app)/home/page.tsx @@ -1,452 +1,52 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { - ArrowRight, - BarChart3, - ChevronDown, - House, - Activity, -} from "lucide-react"; +import { 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 ───────────────────────────────────────────────────── +// ─── Home page header — just the title chrome ─── // -// 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 ─── +// No header actions: Home mixes timeframes (calls · 7d, spend · MTD, relative +// activity), so a page-wide "Period" selector would be misleading, and "View +// usage" is already reachable from the sidebar and the Usage panel below. function HomePageHeader() { - return ( - - - -