diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx
index 6c2d313..79f19d3 100644
--- a/app/(app)/apps/[id]/page.tsx
+++ b/app/(app)/apps/[id]/page.tsx
@@ -597,12 +597,9 @@ export default function AppDetailPage() {
// 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);
+ // Owner/operator chrome — Settings/manage tab + publish controls. The org
+ // owns its own deployed apps; this is the operator layer that re-enables it.
+ const isOwner = isConnected && PIPELINE_APP_IDS.has(id);
// Visibility (publish state) for the owner Settings tab.
const [visibility, setVisibility] = useState(
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)/home/page.tsx b/app/(app)/home/page.tsx
index aa476cc..cf2039e 100644
--- a/app/(app)/home/page.tsx
+++ b/app/(app)/home/page.tsx
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { House } from "lucide-react";
import { useAuth } from "@/components/dashboard/AuthContext";
+import { getOrgFleet } from "@/lib/dashboard/org-fleet";
import DashboardPageHeader from "@/components/dashboard/DashboardPageHeader";
import FirstRunChecklist, {
FIRST_RUN_CHANGED_EVENT,
FIRST_RUN_DISMISSED_KEY,
} from "@/components/dashboard/FirstRunChecklist";
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";
@@ -26,14 +28,18 @@ function HomePageHeader() {
// ─── Home Page ───
//
-// The consumer home: what you're using on the network and what it costs.
-// (Operator/publishing surfaces — deployed apps, deploy onboarding — live in a
-// separate, stacked PR.) Composition:
-// 1. Command bar — org readout + greeting
-// 2. Get started — auto-detecting onboarding: create your account, get your
-// API key, call an app
-// 3. Usage (spend on the apps you call) beside Recent activity (your own
-// calls — a live preview of /calls)
+// "Mission control" rethink: an organization has two relationships with the
+// network — apps it DEPLOYS (which orchestrators serve on its behalf) and apps
+// it CONSUMES (the calls it makes across the network, mostly to apps it didn't
+// deploy). The Home is organized around those two directions, not personas.
+// Composition:
+// 1. Command bar — org readout + 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 (count + their 7-day call volume) beside
+// Usage (spend on the apps you call). Each leads with its own summary.
+// 4. Recent activity — the organization's own calls (what counts toward
+// its usage); a live preview of /calls
export default function HomePage() {
const { isConnected, isLoading, user } = useAuth();
@@ -97,6 +103,10 @@ export default function HomePage() {
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 organization-mock data before the redirect lands.
@@ -131,8 +141,8 @@ export default function HomePage() {
- {/* Onboarding — auto-detecting: get your API key, then call an app.
- Shown until dismissed; full-width above the panels. */}
+ {/* Onboarding — auto-detecting: deploy an example app, then call it.
+ Shown until dismissed; full-width above the console split. */}
{showFirstRun && (
)}
- {/* Usage (what you spend calling apps) beside Recent activity (your
- own calls — a live preview of /calls). Even-height pair. */}
-
-
+ {/* The two ledgers — Deployed apps (what you've published to the
+ network) beside Usage (what you consume from it). Each leads with
+ its own summary, so there's no separate hero band. On a
+ consumer-only org with no deployed apps, Usage carries the row. */}
+ {hasApps ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Recent activity — the organization's own recent calls (what
+ counts toward its usage); a live preview of /calls. */}
+
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
index 124b7eb..7185f8c 100644
--- a/app/(app)/settings/page.tsx
+++ b/app/(app)/settings/page.tsx
@@ -11,12 +11,13 @@ import GeneralSection from "@/components/dashboard/settings/GeneralSection";
import MembersSection from "@/components/dashboard/settings/MembersSection";
import BillingSection from "@/components/dashboard/settings/BillingSection";
import LimitsSection from "@/components/dashboard/settings/LimitsSection";
+import DeployTokensSection from "@/components/dashboard/settings/DeployTokensSection";
import ProfileSection from "@/components/dashboard/settings/ProfileSection";
import NotificationsSection from "@/components/dashboard/settings/NotificationsSection";
import SecuritySection from "@/components/dashboard/settings/SecuritySection";
import AppearanceSection from "@/components/dashboard/settings/AppearanceSection";
-// The 7 settings sub-tabs, two groups (Organization + Account). The sidebar's
+// The 8 settings sub-tabs, two groups (Organization + Account). The sidebar's
// SettingsRail is the navigation surface — there's no horizontal TabStrip on
// this page; the rail and the breadcrumb together tell the user where they
// are. `appearance` is the local-only theme picker (light/dark/system) added
@@ -26,6 +27,7 @@ type SettingsTab =
| "members"
| "billing"
| "usage-limits"
+ | "deploy-tokens"
| "profile"
| "notifications"
| "security"
@@ -36,6 +38,7 @@ const VALID_TABS: SettingsTab[] = [
"members",
"billing",
"usage-limits",
+ "deploy-tokens",
"profile",
"notifications",
"security",
@@ -47,6 +50,7 @@ const TAB_LABELS: Record = {
members: "Members",
billing: "Billing",
"usage-limits": "Limits",
+ "deploy-tokens": "Deploy tokens",
profile: "Profile",
notifications: "Notifications",
security: "Security",
@@ -139,6 +143,7 @@ function SettingsContent() {
{tab === "members" && }
{tab === "billing" && }
{tab === "usage-limits" && }
+ {tab === "deploy-tokens" && }
{tab === "profile" && }
{tab === "notifications" && }
{tab === "security" && }
diff --git a/components/dashboard/AppsHealthPanel.tsx b/components/dashboard/AppsHealthPanel.tsx
new file mode 100644
index 0000000..a750258
--- /dev/null
+++ b/components/dashboard/AppsHealthPanel.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+import StatusDot from "@/components/dashboard/StatusDot";
+import { getOrgFleet, formatCompact } from "@/lib/dashboard/org-fleet";
+import type { Pipeline, PipelineStatusKind } from "@/lib/dashboard/types";
+
+/**
+ * AppsHealthPanel — "Your apps": the org's deployed apps with health AND
+ * traffic in one table (an app's traffic IS its usage, so there's no separate
+ * "health" vs "usage by app" split). Each row carries a status-colored edge bar and an
+ * inline calls-trend sparkline, so "is it up, is it busy" reads in a glance.
+ * Anything needing attention (error → building) sorts to the top and is tinted.
+ * Self-hides for a consumer-only org with no deployed apps.
+ */
+
+const STATUS_META: Record<
+ PipelineStatusKind,
+ {
+ label: string;
+ dot: "green" | "amber" | "red" | "blue";
+ /** Edge-bar + sparkline accent (Tailwind text/bg utility fragments). */
+ bar: string;
+ spark: string;
+ /** Attention tint behind the whole row, or "" for none. */
+ tint: string;
+ rank: number;
+ }
+> = {
+ error: {
+ label: "Error",
+ dot: "red",
+ bar: "bg-red-400",
+ spark: "text-red-400",
+ tint: "bg-red-400/[0.045]",
+ rank: 0,
+ },
+ building: {
+ label: "Building",
+ dot: "amber",
+ bar: "bg-warm",
+ spark: "text-warm",
+ tint: "bg-warm/[0.045]",
+ rank: 1,
+ },
+ deployed: {
+ label: "Deployed",
+ dot: "green",
+ bar: "bg-green-bright",
+ spark: "text-green-bright",
+ tint: "",
+ rank: 2,
+ },
+ stopped: {
+ label: "Stopped",
+ dot: "blue",
+ bar: "bg-blue-bright",
+ spark: "text-fg-disabled",
+ tint: "",
+ rank: 3,
+ },
+};
+
+// Deterministic per-app sparkline — seeded by id so it's stable across renders
+// (no Math.random → no hydration drift) and shaped by status: healthy apps
+// trend gently up, errored apps sag, building apps are flat-and-new.
+function seededSpark(seed: string, status: PipelineStatusKind): number[] {
+ const n = 18;
+ let h = 2166136261;
+ for (let i = 0; i < seed.length; i++) {
+ h = (h ^ seed.charCodeAt(i)) >>> 0;
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ const drift = status === "error" ? -1.4 : status === "deployed" ? 0.9 : 0;
+ const out: number[] = [];
+ let v = status === "building" ? 12 : 50;
+ for (let i = 0; i < n; i++) {
+ h = (Math.imul(h, 1664525) + 1013904223) >>> 0;
+ const noise = ((h % 1000) / 1000 - 0.5) * 22;
+ v = Math.max(6, Math.min(94, v + noise + drift));
+ out.push(v);
+ }
+ return out;
+}
+
+function Sparkline({ data, className }: { data: number[]; className: string }) {
+ const w = 72;
+ const hgt = 22;
+ const max = Math.max(...data);
+ const min = Math.min(...data);
+ const r = max - min || 1;
+ const pts = data
+ .map((v, i) => {
+ const x = (i / (data.length - 1)) * w;
+ const y = hgt - ((v - min) / r) * (hgt - 4) - 2;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+ return (
+
+ );
+}
+
+// Responsive: on mobile only name · calls · p50 fit, so Status and Trend
+// collapse (the colored edge bar still carries status). The hidden cells are
+// display:none below `sm`, so they drop out of grid flow and the 3-track
+// template lines up — matching the cells marked `hidden sm:*` below.
+const GRID =
+ "grid items-center gap-3 pl-5 pr-4 grid-cols-[minmax(0,1fr)_84px_52px] sm:grid-cols-[minmax(0,1fr)_104px_76px_84px_56px]";
+
+export default function AppsHealthPanel() {
+ const { apps, count, totalCalls7d } = getOrgFleet();
+ if (apps.length === 0) return null;
+
+ // Attention first (error → building), then by traffic. The page opens on
+ // whatever matters most.
+ const sorted = [...apps].sort(
+ (a, b) =>
+ STATUS_META[a.deployment.status].rank -
+ STATUS_META[b.deployment.status].rank ||
+ b.deployment.calls7d - a.deployment.calls7d,
+ );
+
+ return (
+
+
+
+
Deployed apps
+
+ All apps
+
+
+
+
+ {/* Hero is the app count (the title is a count-noun, so the big number
+ must agree with it). Call volume — the calls the network handled for
+ these apps, a public count like package downloads — sits as the
+ right-aligned secondary, mirroring Usage. */}
+
+
+ {sorted.map((a: Pipeline, i) => {
+ const s = STATUS_META[a.deployment.status];
+ return (
+ 0 ? "border-t border-hairline" : ""
+ } ${s.tint}`}
+ >
+ {/* Status edge bar — ambient health signal down the left rail. */}
+
+
+
+ {a.name}
+
+
+ {a.deployment.pipelineId}
+
+
+
+
+ {s.label}
+
+ 0 ? s.spark : "text-fg-disabled"}`}
+ >
+
+
+
+ {formatCompact(a.deployment.calls7d)}
+
+
+ {a.deployment.p50LatencyMs > 0 ? `${a.deployment.p50LatencyMs}ms` : "—"}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/components/dashboard/AppsView.tsx b/components/dashboard/AppsView.tsx
new file mode 100644
index 0000000..067b391
--- /dev/null
+++ b/components/dashboard/AppsView.tsx
@@ -0,0 +1,327 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ Box,
+ BookOpen,
+ Check,
+ ChevronRight,
+ Copy,
+ Radio,
+ ScrollText,
+ Terminal,
+} from "lucide-react";
+import DashboardPageHeader from "@/components/dashboard/DashboardPageHeader";
+import TabStrip from "@/components/dashboard/TabStrip";
+import StatusDot from "@/components/dashboard/StatusDot";
+import LogsView from "@/components/dashboard/LogsView";
+import EnvTag from "@/components/dashboard/EnvTag";
+import EnvironmentFilter, {
+ ALL_ENVIRONMENTS,
+} from "@/components/dashboard/EnvironmentFilter";
+import {
+ effectiveVisibility,
+ appsInEnvironment,
+ getEnvironmentById,
+ OWNED_APPS,
+} from "@/lib/dashboard/mock-data";
+import type {
+ AppDeployment,
+ PipelineStatusKind,
+ PipelineVisibility,
+} from "@/lib/dashboard/types";
+
+// ── Status + type presentation ───────────────────────────────────────────────
+
+const STATUS_META: Record<
+ PipelineStatusKind,
+ { label: string; tone: "green" | "amber" | "red" | "blue" }
+> = {
+ deployed: { label: "Deployed", tone: "green" },
+ building: { label: "Building", tone: "amber" },
+ stopped: { label: "Stopped", tone: "blue" },
+ error: { label: "Error", tone: "red" },
+};
+
+function StatusCell({ status }: { status: PipelineStatusKind }) {
+ const s = STATUS_META[status];
+ return (
+
+
+ {s.label}
+
+ );
+}
+
+function TypeBadge({ kind }: { kind: AppDeployment["kind"] }) {
+ const live = kind === "live";
+ return (
+
+ {live ? (
+
+ ) : (
+
+ )}
+ {live ? "Live" : "Batch"}
+
+ );
+}
+
+function VisibilityBadge({ visibility }: { visibility: PipelineVisibility }) {
+ const isPublic = visibility === "public";
+ return (
+
+ {isPublic ? "Public" : "Private"}
+
+ );
+}
+
+// ── Deploy command ────────────────────────────────────────────────────────────
+// Deploying happens through the CLI, not the dashboard, so we surface the real
+// `livepeer push` command rather than a button that pretends to deploy. The
+// `--env` flag follows the page's environment filter so the copied command
+// targets whatever you're looking at (defaulting to production).
+
+function DeployCommand({ envSlug }: { envSlug: string }) {
+ const [copied, setCopied] = useState(false);
+ const command = `livepeer push --env ${envSlug}`;
+
+ const copy = () => {
+ navigator.clipboard?.writeText(command).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+
+
+ $ livepeer push{" "}
+ --env {envSlug}
+
+
+
+ );
+}
+
+// Compact, always-present command strip for the populated list. Not dismissible
+// — it's a reference you copy repeatedly, not a one-time tip.
+function DeployStrip({ envSlug }: { envSlug: string }) {
+ return (
+
+
+
+ Deploy a pipeline
+
+
+
+
+
+ );
+}
+
+// ── Main view ─────────────────────────────────────────────────────────────────
+
+const GRID =
+ "grid grid-cols-[2.2fr_0.8fr_1fr_0.9fr_0.9fr_1fr_16px] items-center gap-3";
+
+export default function AppsView() {
+ const router = useRouter();
+ const [tab, setTab] = useState<"apps" | "logs">("apps");
+
+ // Make the tab addressable: /apps?tab=logs deep-links the Logs view.
+ useEffect(() => {
+ const t = new URLSearchParams(window.location.search).get("tab");
+ if (t === "logs" || t === "apps") setTab(t);
+ }, []);
+ const changeTab = (key: "apps" | "logs") => {
+ setTab(key);
+ router.replace(key === "logs" ? "/apps?tab=logs" : "/apps", {
+ scroll: false,
+ });
+ };
+ const [envFilter, setEnvFilter] = useState(ALL_ENVIRONMENTS);
+
+ const allEnvs = envFilter === ALL_ENVIRONMENTS;
+ const apps = allEnvs ? OWNED_APPS : appsInEnvironment(envFilter);
+ const scopeLabel = allEnvs
+ ? "all environments"
+ : (getEnvironmentById(envFilter)?.name ?? "all environments");
+ // The `--env` flag the deploy command suggests. Follows the filter; defaults
+ // to production when viewing all environments.
+ const deployEnvSlug = allEnvs
+ ? "production"
+ : (getEnvironmentById(envFilter)?.kind ?? "production");
+
+ return (
+ <>
+
+
+
+
+ Docs
+
+ >
+ }
+ />
+
+ {/* Apps (your deployments) + Logs (their aggregated runtime output) — both
+ operator views of the same apps, so they live together here rather than
+ as separate top-level nav items. */}
+
+
+
+
+ {tab === "logs" ? (
+
+ ) : (
+
+
+ Your apps — pipelines deployed with the Livepeer CLI — in{" "}
+ {scopeLabel}.
+ Public apps are listed in Explore; private ones run only for this
+ organization's keys.
+
+
+ {/* Apps table — or, when empty, the deploy explainer */}
+ {apps.length === 0 ? (
+
+
+
+
+
+ No apps in {scopeLabel} yet
+
+
+ Describe your pipeline in a{" "}
+
+ livepeer.yaml
+ {" "}
+ manifest, then push it. The CLI builds the image, embeds the
+ schema, and registers the capability on the network.
+
+ )}
+ >
+ );
+}
diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx
index d851d81..1c6c216 100644
--- a/components/dashboard/DashboardSidebar.tsx
+++ b/components/dashboard/DashboardSidebar.tsx
@@ -22,6 +22,7 @@ import {
PanelLeftClose,
PanelLeftOpen,
Settings as SettingsIcon,
+ Terminal,
User as UserIcon,
Users as UsersIcon,
type LucideIcon,
@@ -35,7 +36,7 @@ import StatusDot from "@/components/dashboard/StatusDot";
import SidebarUsageCard from "@/components/dashboard/SidebarUsageCard";
import OrganizationMenu from "@/components/dashboard/OrganizationMenu";
import Tooltip from "@/components/design-system/Tooltip";
-import { APPS, SETTINGS_API_KEYS } from "@/lib/dashboard/mock-data";
+import { APPS, SETTINGS_API_KEYS, OWNED_APPS } from "@/lib/dashboard/mock-data";
import { formatRuns } from "@/lib/dashboard/utils";
const NAV_ICONS = {
@@ -395,6 +396,7 @@ const SETTINGS_RAIL_GROUPS: {
{ id: "members", label: "Members", icon: UsersIcon, meta: "4" },
{ id: "billing", label: "Billing", icon: CreditCard },
{ id: "usage-limits", label: "Limits", icon: BarChart3 },
+ { id: "deploy-tokens", label: "Deploy tokens", icon: Terminal },
],
},
{
@@ -506,6 +508,8 @@ function SidebarContent({
let meta: string | undefined;
if (!collapsed) {
if (item.href === "/explore") meta = formatRuns(APPS.length);
+ else if (item.href === "/apps")
+ meta = String(OWNED_APPS.length);
else if (item.href === "/keys")
meta = formatRuns(SETTINGS_API_KEYS.length);
}
diff --git a/components/dashboard/FirstRunChecklist.tsx b/components/dashboard/FirstRunChecklist.tsx
index 1df9bca..29e9917 100644
--- a/components/dashboard/FirstRunChecklist.tsx
+++ b/components/dashboard/FirstRunChecklist.tsx
@@ -3,6 +3,7 @@
import { Check } from "lucide-react";
import CopyButton from "@/components/dashboard/CopyButton";
import {
+ OWNED_APPS,
MOCK_RECENT_REQUESTS,
STARTER_API_KEY,
} from "@/lib/dashboard/mock-data";
@@ -18,30 +19,31 @@ interface Props {
}
/**
- * Prototype override. The demo org already has a key + recorded calls, so
- * auto-detection would mark every step done and the checklist would only ever
- * show its completed state. Forcing this keeps the onboarding loop visible in
- * the prototype. Set to `false` once real per-account state backs the steps.
+ * Prototype override. The demo org (Flipbook) already has deployed apps and
+ * recorded calls, so auto-detection would mark both onboarding steps done and
+ * the checklist would only ever show its completed state. Forcing this keeps
+ * the pre-deploy flow visible in the prototype. Set to `false` (or delete this
+ * and the guards below) once real per-account run history backs the steps.
*/
-const MOCK_FORCE_ONBOARDING = true;
+const MOCK_FORCE_PREDEPLOY = true;
/**
- * FirstRunChecklist — the consumer's first loop in three steps: **create your
- * account → get your API key → call an app**. Step 1 completes the moment
- * you're signed in; the rest auto-detect from real state (you have a key ·
- * you've made a call) — no "I've done it" buttons. The active step shows the
- * command to run; finished steps check themselves off.
+ * FirstRunChecklist — operator-first onboarding: the core platform loop in two
+ * steps, **deploy an example app → call it**. Completion is auto-detected from
+ * real state (you have a deployed app · you've made a call) — there are no
+ * "I've done it" buttons to self-attest. The active step shows the command to
+ * run; finished steps check themselves off.
*/
export default function FirstRunChecklist({ onDismiss }: Props) {
- // You're signed in to see this, so the account step is already complete.
- const hasAccount = true;
- const hasKey = !MOCK_FORCE_ONBOARDING && Boolean(STARTER_API_KEY);
- const hasCall = !MOCK_FORCE_ONBOARDING && MOCK_RECENT_REQUESTS.length > 0;
- const allDone = hasAccount && hasKey && hasCall;
+ const hasDeployed =
+ !MOCK_FORCE_PREDEPLOY &&
+ OWNED_APPS.some((a) => a.deployment.status === "deployed");
+ const hasCall = !MOCK_FORCE_PREDEPLOY && MOCK_RECENT_REQUESTS.length > 0;
+ const allDone = hasDeployed && hasCall;
const token = `${STARTER_API_KEY.prefix}_live_…`;
- const keyCmd = "livepeer keys create --name default";
- const callCmd = `curl https://api.livepeer.org/run/flux-schnell -H "Authorization: Bearer ${token}" -d '{"prompt":"a neon city at night"}'`;
+ const deployCmd = "livepeer init hello-world && livepeer push --env production";
+ const callCmd = `curl https://api.livepeer.org/run/hello-world -H "Authorization: Bearer ${token}" -d '{"input":"hello"}'`;
return (
-
@@ -81,7 +74,7 @@ export default function FirstRunChecklist({ onDismiss }: Props) {
- You've got a key and made your first call — you're all set.
+ You've deployed and called an app — that's the whole loop.
{/* Greeting — the only sans-serif line up here, for warmth. */}
{greeting}, {firstName}
+
+
);
}
+
+function Sep() {
+ return (
+
+ ·
+
+ );
+}
+
+function AttentionLine({ fleet }: { fleet: ReturnType }) {
+ const errored = fleet.apps.filter((a) => a.deployment.status === "error");
+ const building = fleet.apps.filter((a) => a.deployment.status === "building");
+
+ // Erroring apps are the top priority — name the offender and route to it.
+ if (errored.length > 0) {
+ const single = errored.length === 1 ? errored[0] : null;
+ return (
+ }
+ href={single ? `/apps/${single.id}?tab=overview` : "/apps"}
+ cta={single ? "Inspect" : "View all"}
+ >
+ {single ? (
+ <>
+ {single.name} is
+ erroring —{" "}
+
+ {single.deployment.errorRatePct.toFixed(0)}%
+ {" "}
+ of calls failed over 7d
+ >
+ ) : (
+ <>
+ {errored.length} apps{" "}
+ are erroring and need attention
+ >
+ )}
+
+ );
+ }
+
+ // Then deploys in flight.
+ if (building.length > 0) {
+ const single = building.length === 1 ? building[0] : null;
+ return (
+
+ }
+ href={single ? `/apps/${single.id}?tab=overview` : "/apps?tab=logs"}
+ cta={single ? "View build" : "View all"}
+ >
+ {single ? (
+ <>
+ {single.name} is
+ building — image and weights are being prepared
+ >
+ ) : (
+ <>
+
+ {building.length} apps
+ {" "}
+ are building
+ >
+ )}
+
+ );
+ }
+
+ // All clear.
+ return (
+ } href="/apps" cta="View all">
+ All systems healthy ·{" "}
+ {fleet.deployed} apps
+ serving traffic
+
+ );
+}
+
+const TONE_RING: Record<"red" | "warm" | "green", string> = {
+ red: "border-red-400/25 bg-red-400/[0.06] text-red-400",
+ warm: "border-warm/25 bg-warm/[0.06] text-warm",
+ green: "border-green-bright/20 bg-green-bright/[0.05] text-green-bright",
+};
+
+function Line({
+ tone,
+ icon,
+ href,
+ cta,
+ children,
+}: {
+ tone: "red" | "warm" | "green";
+ icon: React.ReactNode;
+ href: string;
+ cta: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {icon}
+
+ {children}
+
+
+ {cta}
+
+
+
+ );
+}
diff --git a/components/dashboard/KeysView.tsx b/components/dashboard/KeysView.tsx
index 5c6fc06..b4efe67 100644
--- a/components/dashboard/KeysView.tsx
+++ b/components/dashboard/KeysView.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
import {
AlertTriangle,
BookOpen,
@@ -278,7 +279,15 @@ export default function KeysView() {
. Pass your key as a Bearer token in the Authorization header. Each
key is scoped to one environment — showing{" "}
- {scopeLabel}.
+ {scopeLabel}. To
+ deploy your own apps, use{" "}
+
+ Deploy tokens
+ {" "}
+ instead.
diff --git a/components/dashboard/LogsView.tsx b/components/dashboard/LogsView.tsx
new file mode 100644
index 0000000..28e2384
--- /dev/null
+++ b/components/dashboard/LogsView.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import { ScrollText, Search, Upload } from "lucide-react";
+import StatusDot from "@/components/dashboard/StatusDot";
+import { OWNED_APPS } from "@/lib/dashboard/mock-data";
+import type { Pipeline } from "@/lib/dashboard/types";
+
+// Per-app accent for tagging lines in the aggregated stream.
+const APP_COLORS = ["#40bf86", "#25abd0", "#8b5cf6", "#e5a536", "#d94f70"];
+
+type Level = "info" | "warn" | "error";
+interface LogLine {
+ id: string;
+ time: string;
+ appId: string;
+ appName: string;
+ color: string;
+ level: Level;
+ msg: string;
+}
+
+const LEVEL_COLOR: Record = {
+ info: "text-fg-faint",
+ warn: "text-warm",
+ error: "text-red-400",
+};
+
+// Shared grid template — keeps the column header aligned with the log rows.
+const LOG_GRID = "grid grid-cols-[84px_180px_60px_minmax(0,1fr)] gap-3";
+
+// The lines a single app emits, derived from its kind + status — mirrors the
+// per-app Logs tab on the App detail page, aggregated here across the env.
+function appLines(app: Pipeline): { level: Level; msg: string }[] {
+ if (app.deployment.status === "building") {
+ return [
+ { level: "info", msg: `building image ${app.deployment.image.split("/").pop()}` },
+ { level: "info", msg: "resolving python packages" },
+ { level: "info", msg: "running prepare step (caching weights)" },
+ { level: "warn", msg: "image is large (4.2 GB) — first cold start may be slow" },
+ ];
+ }
+ if (app.deployment.status === "error") {
+ return [
+ { level: "info", msg: "session started — events channel open" },
+ { level: "info", msg: "setup() complete · model=whisper-tiny.en" },
+ { level: "error", msg: "data SSE proxy torn down before final emit_data (go-livepeer#3922)" },
+ { level: "error", msg: "5 records emitted → 3 delivered" },
+ ];
+ }
+ if (app.deployment.kind === "live") {
+ return [
+ { level: "info", msg: "/stream/start · session 8f2a · allocating video track" },
+ { level: "info", msg: "process_video: 24.0 fps in / 23.8 fps out" },
+ { level: "info", msg: "heartbeat" },
+ { level: "info", msg: "process_video: 24.0 fps in / 23.9 fps out" },
+ ];
+ }
+ return [
+ { level: "info", msg: `POST /predict · 200 · ${app.deployment.p50LatencyMs || 60}ms` },
+ { level: "info", msg: "POST /predict · 200 · 71ms" },
+ { level: "warn", msg: "POST /predict · 200 · 184ms (slow — orchestrator cold)" },
+ { level: "info", msg: "POST /predict · 200 · 63ms" },
+ ];
+}
+
+function fmtTime(totalSeconds: number): string {
+ const h = Math.floor(totalSeconds / 3600) % 24;
+ const m = Math.floor(totalSeconds / 60) % 60;
+ const s = totalSeconds % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
+}
+
+// Aggregate every app's lines into one chronological stream (oldest → newest,
+// like a tail). Round-robin interleave so apps appear mixed, then stamp
+// monotonically increasing times.
+function buildLogs(apps: Pipeline[]): LogLine[] {
+ const active = apps.filter((a) => a.deployment.status !== "stopped");
+ const perApp = active.map((app, i) => ({
+ app,
+ color: APP_COLORS[i % APP_COLORS.length],
+ lines: appLines(app),
+ }));
+ const maxLen = perApp.reduce((m, p) => Math.max(m, p.lines.length), 0);
+
+ const merged: Omit[] = [];
+ for (let li = 0; li < maxLen; li++) {
+ for (const p of perApp) {
+ const line = p.lines[li];
+ if (line)
+ merged.push({
+ appId: p.app.id,
+ appName: p.app.name,
+ color: p.color,
+ level: line.level,
+ msg: line.msg,
+ });
+ }
+ }
+
+ // Base ~14:32:00; each line +2–4s so timestamps read like a live tail.
+ const base = 14 * 3600 + 32 * 60;
+ return merged.map((m, idx) => ({
+ id: `log-${idx}`,
+ time: fmtTime(base + idx * 3),
+ ...m,
+ }));
+}
+
+export default function LogsView() {
+ // Logs is a live operational tail across every deployed app, all environments
+ // at once. One entry per app — multi-environment deployments are deduped by
+ // pipelineId so the source list and filter pills don't show an app twice.
+ const apps = useMemo(() => {
+ const seen = new Set();
+ return OWNED_APPS.filter((p) => {
+ if (seen.has(p.deployment.pipelineId)) return false;
+ seen.add(p.deployment.pipelineId);
+ return true;
+ });
+ }, []);
+ const activeApps = apps.filter((a) => a.deployment.status !== "stopped");
+ const [appFilter, setAppFilter] = useState("all");
+ const [query, setQuery] = useState("");
+
+ const logs = useMemo(() => buildLogs(apps), [apps]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return logs.filter(
+ (l) =>
+ (appFilter === "all" || l.appId === appFilter) &&
+ (!q ||
+ l.msg.toLowerCase().includes(q) ||
+ l.appName.toLowerCase().includes(q)),
+ );
+ }, [logs, appFilter, query]);
+
+ return (
+ <>
+ {activeApps.length === 0 ? (
+
+
+
+
+
No logs yet
+
+ Deploy an app and its runtime logs will stream here.
+
+
+
+ Create token
+
+ }
+ />
+
+ {/* Relationship to API keys — the two-credential model made explicit. */}
+
+
+
+ Deploy tokens are organization-scoped{" "}
+ and authorize deploying pipelines
+ to any environment with{" "}
+
+ livepeer push --env
+
+ . To authenticate inference calls,
+ use{" "}
+
+ API keys
+ {" "}
+ instead — those are scoped to a single environment.
+
+
+
+ {/* Token table */}
+
+
+
Token
+
Scope
+
Created
+
Last used
+
By
+
+ {DEPLOY_TOKENS.map((t) => (
+
+
+
+ {t.name}
+
+
+ {t.prefix}
+ …
+
+
+
+ Organization
+
+
{t.created}
+
{t.lastUsed}
+
+ {t.createdBy}
+
+
+ ))}
+
+
+ {/* CLI usage */}
+
+
+
+
+ Quickstart
+
+
+ # authenticate the CLI (once)
+ {"\n"}
+ $
+ livepeer token new
+ {"\n\n"}
+ # deploy a pipeline to an environment
+ {"\n"}
+ $
+ livepeer push
+ --env production
+
+
+
+ {/* Footnote linking back to the call credential */}
+
+
+ Looking for inference API keys? They live under{" "}
+
+ Environment → API keys
+
+ .
+
+
+ );
+}
diff --git a/lib/constants.ts b/lib/constants.ts
index ebaf472..1c02a8f 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -3,10 +3,10 @@
// - "network" → Explore (the global capability catalog you *consume*) and
// Stats (network-wide orchestrator/GPU/payment health).
// Network-wide, not environment-scoped.
-// - "environment" → API keys (your call credential, scoped to the active
-// environment). The environment switcher heads this group.
-// API keys here are the *call* credential (env-scoped,
-// Stripe-style).
+// - "environment" → Apps / Jobs / API keys (your own deployed + operated
+// resources, scoped to the active environment). The
+// environment switcher heads this group. API keys here are
+// the *call* credential (env-scoped, Stripe-style).
// - "organization" → Usage, Calls, and Settings (members, billing, plan,
// profile, deploy tokens) — env-agnostic. Usage is one
// free-tier pool / one bill across all environments
@@ -20,6 +20,7 @@ export const PORTAL_NAV_ITEMS = [
{ label: "Home", href: "/home", icon: "House" as const, kbd: "G H", zone: "home" as const },
{ label: "Explore", href: "/explore", icon: "LayoutGrid" as const, zone: "network" as const },
{ label: "Stats", href: "/network", icon: "Globe" as const, zone: "network" as const },
+ { label: "Apps", href: "/apps", icon: "Box" as const, zone: "environment" as const },
{ label: "API keys", href: "/keys", icon: "Key" as const, zone: "environment" as const },
{ label: "Usage", href: "/usage", icon: "BarChart3" as const, zone: "organization" as const },
{ label: "Calls", href: "/calls", icon: "Activity" as const, zone: "organization" as const },
diff --git a/lib/dashboard/mock-data.ts b/lib/dashboard/mock-data.ts
index 4f1f259..3fc0978 100644
--- a/lib/dashboard/mock-data.ts
+++ b/lib/dashboard/mock-data.ts
@@ -2,6 +2,7 @@ import type {
Environment,
AppCategory,
AppDeployment,
+ Pipeline,
PipelineKind,
PipelineStatusKind,
PipelineEndpoint,
@@ -1751,6 +1752,20 @@ export const PIPELINE_APP_IDS: ReadonlySet = new Set(
APPS.filter((a) => a.provider === OWNED_APP_PROVIDER).map((a) => a.id),
);
+/**
+ * The org's own deployed apps as `Pipeline`s (every one carries a deployment
+ * manifest, so `app.deployment` is non-optional here). This is the operator's
+ * fleet — the per-environment deployment rows the Apps/Logs surfaces iterate.
+ */
+export const OWNED_APPS: Pipeline[] = APPS.filter(
+ (a): a is Pipeline => a.provider === OWNED_APP_PROVIDER && !!a.deployment,
+);
+
+/** The org's apps deployed into a given environment. */
+export function appsInEnvironment(environmentId: string): Pipeline[] {
+ return OWNED_APPS.filter((a) => a.deployment.environmentId === environmentId);
+}
+
/** All apps sharing a `pipelineId` — the per-environment deployments. */
export function deploymentsForPipeline(pipelineId: string): App[] {
return APPS.filter((a) => a.deployment?.pipelineId === pipelineId);