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. */} +
+ + {count} + + + {count === 1 ? "app" : "apps"} + + + {formatCompact(totalCalls7d)} calls · 7d + +
+
+ +
+ App + Status + Trend + Calls · 7d + p50 +
+ + {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. */} +
+ ); +} 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 ? ( + + ); +} + +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 ( +
+ + +
+ +
+
+ ); +} + +// ── 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 ( + <> + + + + + + } + /> + + {/* 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. +

+
+ +
+
+ ) : ( + <> + +
+
+
App
+
Type
+
Status
+
Visibility
+
Calls · 7d
+
p50
+
+
+ {apps.map((app) => { + const visibility = effectiveVisibility(app); + return ( + + {/* App name + pipeline id */} +
+
+ + {app.name} + + {allEnvs && } +
+
+ {app.deployment.pipelineId} +
+
+
+ +
+
+ +
+
+ +
+
+ {app.deployment.calls7d.toLocaleString()} +
+
+ {app.deployment.p50LatencyMs > 0 ? `${app.deployment.p50LatencyMs}ms` : "—"} +
+
+
+ + ); + })} +
+ + )} + + {apps.length > 0 && ( +

+ Showing {apps.length} {apps.length === 1 ? "app" : "apps"} in{" "} + {scopeLabel}. +

+ )} +
+ )} + + ); +} 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 ( + + ); + } + + // Then deploys in flight. + if (building.length > 0) { + const single = building.length === 1 ? building[0] : null; + return ( + + ); + } + + // 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. +

+ +
+ ) : ( + <> + {/* Filter bar — app filter pills + search */} +
+ + + live + +
+ + {/* Column header — aligns with the grid rows below. */} +
+ Time + Source + Level + Message +
+ + {/* Log stream */} +
+ {filtered.length === 0 ? ( +

+ No log lines match your filter. +

+ ) : ( + filtered.map((l) => ( +
+ {l.time} + + + + {l.level} + + + {l.msg} + +
+ )) + )} +
+ + )} + + ); +} + +function FilterPill({ + label, + color, + active, + onClick, +}: { + label: string; + color?: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/components/dashboard/settings/DeployTokensSection.tsx b/components/dashboard/settings/DeployTokensSection.tsx new file mode 100644 index 0000000..c0e3361 --- /dev/null +++ b/components/dashboard/settings/DeployTokensSection.tsx @@ -0,0 +1,166 @@ +"use client"; + +import Link from "next/link"; +import { Info, KeyRound, Plus, Terminal } from "lucide-react"; +import { + SettingsHeader, + SettingsCard, + IconButton, + RolePill, + ST_COLS_5, + ST_HEAD_CLASS, +} from "@/components/dashboard/settings/SettingsPrimitives"; + +// ─── Mock deploy tokens ────────────────────────────────────────────────────── +// +// Deploy tokens are the *organization-level* credential the Livepeer CLI / Runner +// SDK uses to push pipelines (`livepeer push --env `). Unlike API keys — +// which are environment-scoped and authenticate inference *calls* — one deploy +// token can target any environment via the `--env` flag, mirroring Modal's +// organization-level tokens. Admin-privileged: they can build, register, and stop +// capabilities on the network. + +interface DeployToken { + id: string; + name: string; + prefix: string; + created: string; + lastUsed: string; + createdBy: string; +} + +const DEPLOY_TOKENS: DeployToken[] = [ + { + id: "dt_1", + name: "CI · GitHub Actions", + prefix: "lp_deploy_t8x2", + created: "Apr 22, 2026", + lastUsed: "2 hours ago", + createdBy: "Zain", + }, + { + id: "dt_2", + name: "Zain · laptop", + prefix: "lp_deploy_q4m9", + created: "Mar 30, 2026", + lastUsed: "yesterday", + createdBy: "Zain", + }, +]; + +export default function DeployTokensSection() { + return ( +
+ +
+ ); +} 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);