diff --git a/app/(dashboard)/alerts/page.tsx b/app/(dashboard)/alerts/page.tsx index acd47e4..c7f7ea1 100644 --- a/app/(dashboard)/alerts/page.tsx +++ b/app/(dashboard)/alerts/page.tsx @@ -84,11 +84,11 @@ function ChannelConfigForm({ channel, onSave, onTest, onDelete, onClose }: { {CHANNEL_ICON[channel.type]} {channel.name} - ✕ + ✕
Last delivery: {timeAgo(channel.lastDeliveryAt)} ·{" "} {channel.lastDeliveryOk ? "OK" : "Failed"} @@ -129,7 +129,7 @@ function ChannelConfigForm({ channel, onSave, onTest, onDelete, onClose }: { onDelete(channel.id)} - className="ml-auto text-[10px] text-[#555] hover:text-red transition-colors" + className="ml-auto text-[10px] text-[#949494] hover:text-red transition-colors" > Delete @@ -158,7 +158,7 @@ function AddChannelForm({ onCreate, onClose }: { New channel - ✕ + ✕ ( - {stat.label} + {stat.label} {stat.value} @@ -309,7 +309,7 @@ export default function AlertsPage() { key={tab} onClick={() => setActiveTab(tab)} className={`px-3 py-1.5 text-xs rounded capitalize font-medium transition-colors ${ - activeTab === tab ? "bg-blue text-white" : "text-[#555] hover:text-[#888]" + activeTab === tab ? "bg-blue text-white" : "text-[#949494] hover:text-[#888]" }`} > {tab} @@ -324,11 +324,11 @@ export default function AlertsPage() { {/* Enhanced rule builder */} - Rule Builder + Rule Builder {/* Main condition row */} - Alert when + Alert when setMetric(e.target.value)} @@ -354,19 +354,19 @@ export default function AlertsPage() { onChange={(e) => setThreshold(e.target.value)} className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1 text-xs text-[#F5F5F5] outline-none w-16 focus:border-blue transition-colors" /> - {metric === "Error Rate" ? "%" : "ms"} for + {metric === "Error Rate" ? "%" : "ms"} for setWindow(e.target.value)} className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1 text-xs text-[#F5F5F5] outline-none w-14 focus:border-blue transition-colors" /> - min + min {/* Severity + channel row */} - Severity + Severity {SEVERITIES.map((s) => ( @@ -385,7 +385,7 @@ export default function AlertsPage() { ))} - Send to + Send to setChannelName(e.target.value)} @@ -406,7 +406,7 @@ export default function AlertsPage() { {/* Preview */} - + {preview} @@ -431,7 +431,7 @@ export default function AlertsPage() { {connected ? "connected" : "not configured"} {ch.lastDeliveryAt && ( - + {timeAgo(ch.lastDeliveryAt)} · {ch.lastDeliveryOk ? "OK" : "failed"} )} @@ -459,7 +459,7 @@ export default function AlertsPage() { ); })} {channels.length === 0 && !addingChannel && ( - + No channels yet. Add one to receive alert notifications. )} @@ -469,7 +469,7 @@ export default function AlertsPage() { ) : ( setAddingChannel(true)} - className="rounded-lg border border-dashed border-[#2A2A2A] bg-transparent p-4 flex items-center justify-center gap-2 text-[#444] hover:text-[#888] hover:border-[#333] transition-colors w-full" + className="rounded-lg border border-dashed border-[#2A2A2A] bg-transparent p-4 flex items-center justify-center gap-2 text-[#808080] hover:text-[#888] hover:border-[#333] transition-colors w-full" > Add Channel @@ -482,14 +482,14 @@ export default function AlertsPage() { {activeTab === "history" && ( {historyEntries.length === 0 && ( - No alert history yet. + No alert history yet. )} {historyEntries.map((h) => ( {h.ruleName} - + {timeAgo(h.firedAt)} · {h.duration} · via {h.channel} diff --git a/app/(dashboard)/endpoints/page.tsx b/app/(dashboard)/endpoints/page.tsx index 2874a2e..8ebef61 100644 --- a/app/(dashboard)/endpoints/page.tsx +++ b/app/(dashboard)/endpoints/page.tsx @@ -112,7 +112,7 @@ export default function EndpointsPage() { {summary.critical} Critical - {filtered.length} endpoints + {filtered.length} endpoints + {compareSelection.length === 0 ? "Select 2 endpoints to compare" : `${compareSelection.length}/2 selected — pick one more`} {compareSelection.length > 0 && ( - setCompareSelection([])} className="ml-auto text-[#444] hover:text-[#888]"> + setCompareSelection([])} className="ml-auto text-[#808080] hover:text-[#888]"> )} @@ -151,13 +151,13 @@ export default function EndpointsPage() { {/* Route search */} - + setSearch(e.target.value)} - className="bg-transparent text-xs text-[#F5F5F5] placeholder-[#333] outline-none flex-1" + className="bg-transparent text-xs text-[#F5F5F5] placeholder-[#808080] outline-none flex-1" /> @@ -168,7 +168,7 @@ export default function EndpointsPage() { key={m} onClick={() => setMethodFilter(m)} className={`px-2 py-1 text-[10px] font-bold rounded transition-colors ${ - methodFilter === m ? "bg-blue text-white" : "text-[#555] hover:text-[#888]" + methodFilter === m ? "bg-blue text-white" : "text-[#949494] hover:text-[#888]" }`} > {m} @@ -189,7 +189,7 @@ export default function EndpointsPage() { : label === "4xx" ? "bg-yellow/20 text-yellow" : "bg-red/20 text-red" - : "text-[#555] hover:text-[#888]" + : "text-[#949494] hover:text-[#888]" }`} > {label} @@ -199,17 +199,17 @@ export default function EndpointsPage() { {/* Latency threshold */} - p99 < + p99 < setMaxLatency(e.target.value)} - className="bg-transparent text-xs text-[#F5F5F5] placeholder-[#333] outline-none w-14 text-right tabular-nums" + className="bg-transparent text-xs text-[#F5F5F5] placeholder-[#808080] outline-none w-14 text-right tabular-nums" /> - ms + ms {maxLatency && ( - setMaxLatency("")} className="text-[#333] hover:text-[#666]"> + setMaxLatency("")} className="text-[#808080] hover:text-[#949494]"> )} diff --git a/app/(dashboard)/overview/page.tsx b/app/(dashboard)/overview/page.tsx index cb62e8c..bb9ad94 100644 --- a/app/(dashboard)/overview/page.tsx +++ b/app/(dashboard)/overview/page.tsx @@ -5,11 +5,12 @@ import { TimeRangePicker } from "@/components/layout/time-range-picker"; import { StatCardComponent } from "@/components/dashboard/stat-card"; import { RequestsChart } from "@/components/charts/requests-chart"; import { ErrorRateChart } from "@/components/charts/error-rate-chart"; +import { EmptyChartState } from "@/components/charts/empty-chart-state"; import { LiveLogTable } from "@/components/dashboard/live-log-table"; import { IncidentPanel } from "@/components/dashboard/incident-panel"; import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { useTimeRange } from "@/hooks/use-time-range"; -import { useMetrics, useIncidentSocket } from "@/hooks/use-websocket"; +import { useMetrics, useIncidentSocket, useLiveChartData } from "@/hooks/use-websocket"; import { useChartData, useIncidents } from "@/hooks/use-data"; import { useProjects } from "@/components/providers/project-provider"; @@ -18,22 +19,31 @@ const SPARK_COLORS = ["#378ADD", "#EF4444", "#F59E0B", "#22C55E"]; export default function OverviewPage() { const { range, setRange, hours } = useTimeRange("24h"); const { activeProject } = useProjects(); + const isLive = range === "live"; - // REST: chart timeseries + incidents (real data; mock while loading / API down) - const { data: chartData } = useChartData(hours); + // REST: chart timeseries + incidents — real data only; empty while + // loading, on error, or genuinely no traffic in range (never fabricated). + // In "live" mode the REST range is irrelevant (not displayed) — pass a + // harmless valid value rather than skipping the hook (Rules of Hooks). + const { data: restChartData } = useChartData(isLive ? 1 : hours); const { data: incidents } = useIncidents(); // Live stat cards + incidents via Socket.io — project-scoped realtime token - // fetched from the BFF; falls back to demo data while connecting / keyless. + // fetched from the BFF. const projectId = activeProject?.id ?? null; const cards = useMetrics(projectId); const liveIncidents = useIncidentSocket(projectId); + // Rolling ~2 min buffer from the same real-time metric stream as the stat + // cards — only actually connects while "Live" is selected below. + const liveChartData = useLiveChartData(isLive ? projectId : null); + + const chartData = isLive ? liveChartData : restChartData; return ( } + actions={} /> @@ -51,9 +61,15 @@ export default function OverviewPage() { Requests Over Time - req/min + req/min - + {chartData.length > 0 ? ( + + ) : ( + + )} @@ -66,12 +82,18 @@ export default function OverviewPage() { ].map((l) => ( - {l.label} + {l.label} ))} - + {chartData.length > 0 ? ( + + ) : ( + + )} diff --git a/app/(dashboard)/pipelines/page.tsx b/app/(dashboard)/pipelines/page.tsx deleted file mode 100644 index 66a43cd..0000000 --- a/app/(dashboard)/pipelines/page.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Topbar } from "@/components/layout/topbar"; -import { TimeRangePicker } from "@/components/layout/time-range-picker"; -import { PipelineArchitecture } from "@/components/dashboard/pipeline-architecture"; -import { ActivePipeline } from "@/components/dashboard/active-pipeline"; -import { StatCardComponent } from "@/components/dashboard/stat-card"; -import { useTimeRange } from "@/hooks/use-time-range"; -import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; -import type { StatCard } from "@/lib/types"; - -const PIPELINE_CARDS: StatCard[] = [ - { label: "Processed Prompts", value: "124", unit: "k", delta: 15, deltaLabel: "vs yesterday", sparkline: [80,90,95,110,100,115,120,124,118,122,119,124] }, - { label: "Avg. Latency", value: "850", unit: "ms", delta: 45, deltaLabel: "vs yesterday", sparkline: [700,720,800,810,790,830,845,850,840,855,848,850] }, - { label: "Total Cost", value: "$452", delta: -120, deltaLabel: "vs yesterday", sparkline: [580,560,540,510,490,480,465,460,455,452,454,452] }, - { label: "Blocked Requests", value: "2.4", unit: "k", delta: 4, deltaLabel: "vs yesterday", sparkline: [1.8,1.9,2.0,2.1,2.0,2.2,2.3,2.35,2.4,2.38,2.4,2.4] }, -]; - -const PIPELINE_LIST = [ - { name: "Customer Support Bot", status: "healthy", requests: "124k", model: "GPT-4o / Claude" }, - { name: "Code Review Agent", status: "healthy", requests: "38k", model: "Claude Sonnet" }, - { name: "Document Summarizer", status: "warning", requests: "12k", model: "Gemini Flash" }, - { name: "Email Classifier", status: "healthy", requests: "89k", model: "GPT-4o Mini" }, -]; - -export default function PipelinesPage() { - const { range, setRange } = useTimeRange("24h"); - const [activePipeline, setActivePipeline] = useState(PIPELINE_LIST[0].name); - - return ( - - - - - - New Flow - - > - } - /> - - {/* Overview/Metrics/Evaluations tabs */} - - {["Overview", "Metrics", "Evaluations"].map((tab, i) => ( - - {tab} - - ))} - - - - {/* Left: pipeline list */} - - Pipelines - {PIPELINE_LIST.map((p) => ( - setActivePipeline(p.name)} - className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors ${ - activePipeline === p.name - ? "bg-blue-dim text-blue border border-blue-border" - : "text-[#888] hover:text-[#F5F5F5] hover:bg-[#161616] border border-transparent" - }`} - > - - {p.name} - - ))} - - - {/* Main content */} - - {/* Stat cards */} - - {PIPELINE_CARDS.map((card, i) => { - const COLORS = ["#378ADD", "#F59E0B", "#22C55E", "#EF4444"]; - return ( - - ); - })} - - - - - - {/* Right: Active pipeline architecture */} - - - - - - ); -} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..1a7310a --- /dev/null +++ b/app/(dashboard)/settings/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { signOut, useSession } from "next-auth/react"; +import { LogOut } from "lucide-react"; +import { Topbar } from "@/components/layout/topbar"; +import { Button } from "@/components/ui/button"; +import { useProjects } from "@/components/providers/project-provider"; +import { ProjectSettingsCard } from "@/components/settings/project-settings-card"; + +function Avatar({ name, image }: { name?: string | null; image?: string | null }) { + if (image) { + // eslint-disable-next-line @next/next/no-img-element + return ; + } + const initials = name + ? name.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase() + : "?"; + return ( + + {initials} + + ); +} + +export default function SettingsPage() { + const { data: session } = useSession(); + const { projects, loading } = useProjects(); + + return ( + + + + + {/* Account */} + + Account + + + + + {session?.user?.name ?? "—"} + {session?.user?.email ?? "—"} + + + signOut({ callbackUrl: "/auth/signin" })}> + Sign out + + + + + {/* Projects */} + + Projects + {projects.length > 0 ? ( + // Once there's something to show, keep showing it — every mutation + // (rotate/toggle/delete) triggers a background refresh() that flips + // `loading` true, and gating on that here would unmount every card + // (losing e.g. the just-rotated-key display) on every single action. + + {projects.map((project) => ( + + ))} + + ) : loading ? ( + + Loading… + + ) : ( + + No projects yet. + + )} + + + + ); +} diff --git a/app/(dashboard)/traces/page.tsx b/app/(dashboard)/traces/page.tsx index f41734c..82155ae 100644 --- a/app/(dashboard)/traces/page.tsx +++ b/app/(dashboard)/traces/page.tsx @@ -3,12 +3,14 @@ import { useState, useEffect, useMemo } from "react"; import { Search, GitBranch, Map } from "lucide-react"; import { Topbar } from "@/components/layout/topbar"; +import { TimeRangePicker } from "@/components/layout/time-range-picker"; import { FlameGraph } from "@/components/traces/flame-graph"; import { SpanDetail } from "@/components/traces/span-detail"; import { ServiceMap } from "@/components/traces/service-map"; import { Badge } from "@/components/ui/badge"; import { cn, formatMs, timeAgo } from "@/lib/utils"; import { useTraces } from "@/hooks/use-data"; +import { useTimeRange } from "@/hooks/use-time-range"; import { useProjects } from "@/components/providers/project-provider"; import type { Trace, TraceSpan } from "@/lib/types"; @@ -36,7 +38,8 @@ type ViewMode = "flamegraph" | "servicemap"; export default function TracesPage() { const { activeProject } = useProjects(); - const { data: traces } = useTraces(); + const { range, setRange, hours } = useTimeRange("24h"); + const { data: traces } = useTraces(hours); const [selectedTrace, setSelectedTrace] = useState(null); // Keep the selection in sync with the current trace list: clear it when the @@ -64,7 +67,10 @@ export default function TracesPage() { return ( - + } + /> {/* Trace list */} @@ -72,13 +78,13 @@ export default function TracesPage() { {/* Search */} - + setSearch(e.target.value)} - className="bg-transparent text-[10px] text-[#F5F5F5] placeholder-[#333] outline-none flex-1" + className="bg-transparent text-[10px] text-[#F5F5F5] placeholder-[#808080] outline-none flex-1" /> {/* Duration filter */} @@ -91,7 +97,7 @@ export default function TracesPage() { "px-2 py-0.5 text-[9px] rounded border transition-colors", durationFilter === opt.value ? "border-blue bg-blue/10 text-blue" - : "border-[#1E1E1E] text-[#444] hover:text-[#888] hover:border-[#2A2A2A]" + : "border-[#1E1E1E] text-[#808080] hover:text-[#888] hover:border-[#2A2A2A]" )} > {opt.label} @@ -111,14 +117,14 @@ export default function TracesPage() { )} > - {trace.id} + {trace.id} {trace.status} {trace.endpoint} - {timeAgo(trace.timestamp)} + {timeAgo(trace.timestamp)} 1000 ? "text-red" : trace.totalDuration > 500 ? "text-yellow" : "text-green" @@ -129,7 +135,7 @@ export default function TracesPage() { ))} {filtered.length === 0 && ( - No traces match + No traces match )} @@ -141,12 +147,12 @@ export default function TracesPage() { {/* Trace header + toolbar */} - Trace + Trace {selectedTrace.id} - {selectedTrace.spans.length} spans - {timeAgo(selectedTrace.timestamp)} + {selectedTrace.spans.length} spans + {timeAgo(selectedTrace.timestamp)} {selectedTrace.status} @@ -161,7 +167,7 @@ export default function TracesPage() { "flex items-center gap-1.5 px-2.5 py-1 rounded border text-[10px] transition-colors", criticalPath ? "border-[#F59E0B]/40 bg-[#F59E0B]/10 text-[#F59E0B]" - : "border-[#1E1E1E] text-[#444] hover:text-[#888] hover:border-[#2A2A2A]" + : "border-[#1E1E1E] text-[#808080] hover:text-[#888] hover:border-[#2A2A2A]" )} > @@ -175,7 +181,7 @@ export default function TracesPage() { onClick={() => setViewMode("flamegraph")} className={cn( "flex items-center gap-1 px-2 py-1 text-[10px] rounded transition-colors", - viewMode === "flamegraph" ? "bg-blue text-white" : "text-[#555] hover:text-[#888]" + viewMode === "flamegraph" ? "bg-blue text-white" : "text-[#949494] hover:text-[#888]" )} > @@ -185,7 +191,7 @@ export default function TracesPage() { onClick={() => setViewMode("servicemap")} className={cn( "flex items-center gap-1 px-2 py-1 text-[10px] rounded transition-colors", - viewMode === "servicemap" ? "bg-blue text-white" : "text-[#555] hover:text-[#888]" + viewMode === "servicemap" ? "bg-blue text-white" : "text-[#949494] hover:text-[#888]" )} > @@ -211,7 +217,7 @@ export default function TracesPage() { )} > ) : ( - + Select a trace to view the flame graph )} diff --git a/app/api/lb/[...path]/route.ts b/app/api/lb/[...path]/route.ts index 2eab36d..656c724 100644 --- a/app/api/lb/[...path]/route.ts +++ b/app/api/lb/[...path]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; +import { verifyProjectAccess } from "@/lib/project-access"; /** * Session-scoped read/write proxy for dashboard telemetry. @@ -7,9 +8,11 @@ import { auth } from "@/auth"; * browser (no key) → here (NextAuth session) → backend (internal token + project) * * Enforces that the requested `?project=` belongs to the signed-in user - * (via `session.projectIds`), then forwards to the FastAPI backend with the - * server-only internal token and an explicit `x-project-id`. The browser never - * holds a project API key or the internal token. + * with a fresh membership check (see lib/project-access.ts — a cached + * session-level list goes stale the moment a project is created after + * login), then forwards to the FastAPI backend with the server-only internal + * token and an explicit `x-project-id`. The browser never holds a project API + * key or the internal token. */ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; @@ -25,7 +28,7 @@ async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) const reqUrl = new URL(req.url); const project = reqUrl.searchParams.get("project"); if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 }); - if (!session.projectIds?.includes(project)) { + if (!(await verifyProjectAccess(email, project))) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } diff --git a/app/api/projects/[id]/status-page/route.ts b/app/api/projects/[id]/status-page/route.ts new file mode 100644 index 0000000..ac43f58 --- /dev/null +++ b/app/api/projects/[id]/status-page/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +export async function PATCH( + req: Request, + ctx: { params: Promise<{ id: string }> } +) { + const session = await auth(); + const email = session?.user?.email; + if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + + const { enabled } = (await req.json()) as { enabled: boolean }; + const { id } = await ctx.params; + const res = await fetch( + `${API_URL}/v1/internal/projects/${encodeURIComponent(id)}/status-page`, + { + method: "PATCH", + headers: { "content-type": "application/json", "x-internal-token": internalToken() }, + body: JSON.stringify({ email, enabled }), + cache: "no-store", + } + ); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/app/api/realtime-token/route.ts b/app/api/realtime-token/route.ts index 805275f..632dccd 100644 --- a/app/api/realtime-token/route.ts +++ b/app/api/realtime-token/route.ts @@ -1,9 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; +import { verifyProjectAccess } from "@/lib/project-access"; // Mints a short-lived, project-scoped realtime token for the browser's -// Socket.io / SSE connections — only after verifying the signed-in user is a -// member of the requested project. The browser never holds an API key. +// Socket.io / SSE connections — only after verifying (fresh, not cached — +// see lib/project-access.ts) the signed-in user is a member of the +// requested project. The browser never holds an API key. const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; function internalToken(): string { @@ -17,7 +19,7 @@ export async function GET(req: Request) { const project = new URL(req.url).searchParams.get("project"); if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 }); - if (!session.projectIds?.includes(project)) { + if (!(await verifyProjectAccess(email, project))) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 7cf301e..f189a50 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -50,7 +50,7 @@ function SignInForm() { Welcome back - Sign in to your Liveboard workspace + Sign in to your Liveboard workspace {error && ( @@ -76,7 +76,7 @@ function SignInForm() { {hasGoogle && ( - dev login + dev login )} @@ -124,15 +124,15 @@ function SignInForm() { )} - + By signing in you agree to our{" "} - Terms of Service + Terms of Service {" "}and{" "} - Privacy Policy. + Privacy Policy. - Open source API observability + Open source API observability ); diff --git a/app/status/[slug]/page.tsx b/app/status/[slug]/page.tsx new file mode 100644 index 0000000..de00649 --- /dev/null +++ b/app/status/[slug]/page.tsx @@ -0,0 +1,134 @@ +import { notFound } from "next/navigation"; +import { LiveboardIcon } from "@/components/logo"; +import { UptimeBar } from "@/components/status/uptime-bar"; +import { OverallBanner } from "@/components/status/overall-banner"; +import { IncidentCard } from "@/components/status/incident-card"; +import { SubscribeForm } from "@/components/status/subscribe-form"; +import { AutoRefresh } from "@/components/status/auto-refresh"; +import type { ServiceStatus, Incident } from "@/lib/types"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +interface ApiService { + id: string; + name: string; + uptime90d: number; + current_status: ServiceStatus["currentStatus"]; + response_time: number; + uptime_bars: ServiceStatus["uptimeBars"]; +} + +interface ApiIncident { + id: string; + severity: Incident["severity"]; + title: string; + summary: string; + endpoint: string; + timestamp: string; + resolved: boolean; +} + +interface PublicStatus { + project_name: string; + services: ApiService[]; + incidents: ApiIncident[]; +} + +function toServices(raw: ApiService[]): ServiceStatus[] { + return raw.map((s) => ({ + id: s.id, + name: s.name, + uptime90d: s.uptime90d, + currentStatus: s.current_status, + responseTime: s.response_time, + uptimeBars: s.uptime_bars, + })); +} + +function toIncidents(raw: ApiIncident[]): Incident[] { + return raw.map((i) => ({ + id: i.id, + severity: i.severity, + title: i.title, + summary: i.summary, + endpoint: i.endpoint, + timestamp: new Date(i.timestamp), + resolved: i.resolved, + })); +} + +/** + * The actual public status page — no session, no ProjectProvider, no + * localStorage. Server-rendered per request straight from the FastAPI + * public-status endpoint, scoped by `slug` alone, exactly like a real + * status page a logged-out visitor (or your customer's customer) can open. + */ +export default async function PublicStatusPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + const res = await fetch(`${API_URL}/v1/public/status/${encodeURIComponent(slug)}`, { + cache: "no-store", + }); + if (res.status === 404) notFound(); + if (!res.ok) throw new Error(`status API ${res.status}`); + + const data = (await res.json()) as PublicStatus; + const services = toServices(data.services); + const incidents = toIncidents(data.incidents); + const hasDegraded = services.some((s) => s.currentStatus !== "operational"); + + return ( + + + + + + + + {data.project_name} Status + + + + + + + + + + Components + + + {services.map((service) => ( + + ))} + {services.length === 0 && ( + No components to report yet. + )} + + + + + + + Incident History + + + {incidents.filter((i) => !i.resolved).length} active + + + + {incidents.map((incident) => ( + + ))} + + + + + + + + + ); +} diff --git a/app/status/confirm/[token]/page.tsx b/app/status/confirm/[token]/page.tsx new file mode 100644 index 0000000..8f1472a --- /dev/null +++ b/app/status/confirm/[token]/page.tsx @@ -0,0 +1,31 @@ +import { CheckCircle2, XCircle } from "lucide-react"; +import { LiveboardIcon } from "@/components/logo"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +export default async function ConfirmSubscriptionPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + const res = await fetch( + `${API_URL}/v1/public/status/subscriptions/${encodeURIComponent(token)}/confirm`, + { cache: "no-store" }, + ); + const data = (await res.json()) as { ok: boolean; detail: string }; + + return ( + + + + {data.ok ? ( + + ) : ( + + )} + {data.detail} + + + ); +} diff --git a/app/status/page.tsx b/app/status/page.tsx index 805b8a4..40442e8 100644 --- a/app/status/page.tsx +++ b/app/status/page.tsx @@ -1,165 +1,37 @@ "use client"; -import { useState, useMemo } from "react"; -import { CheckCircle2, AlertTriangle, XCircle, Mail, ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ExternalLink } from "lucide-react"; import { LiveboardIcon } from "@/components/logo"; import { UptimeBar } from "@/components/status/uptime-bar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { OverallBanner } from "@/components/status/overall-banner"; +import { IncidentCard } from "@/components/status/incident-card"; +import { SubscribeForm } from "@/components/status/subscribe-form"; import { useIncidents, useServices } from "@/hooks/use-data"; -import { timeAgo } from "@/lib/utils"; -import type { Incident } from "@/lib/types"; - -const OVERALL_STATUS = { - operational: { - label: "All Systems Operational", - color: "text-green", - bg: "bg-green/10 border-green/20", - icon: CheckCircle2, - }, - degraded: { - label: "Degraded Performance", - color: "text-yellow", - bg: "bg-yellow/10 border-yellow/20", - icon: AlertTriangle, - }, - partial_outage: { - label: "Partial Outage", - color: "text-red", - bg: "bg-red/10 border-red/20", - icon: XCircle, - }, -}; - -// ─── Incident timeline data ─────────────────────────────────────────────────── - -type TimelineStep = "investigating" | "identified" | "monitoring" | "resolved"; - -const STEP_COLORS: Record = { - investigating: "#EF4444", - identified: "#F59E0B", - monitoring: "#378ADD", - resolved: "#22C55E", -}; - -const STEP_ORDER: TimelineStep[] = ["investigating", "identified", "monitoring", "resolved"]; - -function buildTimeline(incident: Incident): { step: TimelineStep; time: Date; note: string; done: boolean }[] { - const base = incident.timestamp.getTime(); - const steps: TimelineStep[] = incident.resolved - ? ["investigating", "identified", "monitoring", "resolved"] - : ["investigating", "identified"]; - - const offsets = [0, 8 * 60000, 22 * 60000, 35 * 60000]; - const notes: Record = { - investigating: "Incident detected and engineers paged.", - identified: `Root cause identified: elevated error rate on ${incident.endpoint}.`, - monitoring: "Fix deployed, monitoring for stability.", - resolved: "All systems back to normal. Post-mortem scheduled.", - }; - - return STEP_ORDER.map((step, i) => ({ - step, - time: new Date(base + offsets[i]!), - note: notes[step], - done: steps.includes(step), - })); -} - -// ─── IncidentCard with expandable timeline ──────────────────────────────────── - -function IncidentCard({ incident }: { incident: Incident }) { - const [expanded, setExpanded] = useState(!incident.resolved); - const timeline = useMemo(() => buildTimeline(incident), [incident]); - const currentStep = incident.resolved ? "resolved" : timeline.filter((t) => t.done).slice(-1)[0]?.step ?? "investigating"; - - return ( - - {/* Header */} - setExpanded((v) => !v)} - className="w-full p-4 text-left hover:bg-[#151515] transition-colors" - > - - - - {incident.severity} - - {incident.title} - - - - {incident.resolved ? "Resolved" : currentStep} - - - - - {incident.summary} - - {timeAgo(incident.timestamp)} - · - {incident.endpoint} - - - - {/* Timeline */} - {expanded && ( - - Timeline - - {/* Vertical connector */} - - - - {timeline.map(({ step, time, note, done }) => ( - - {/* Step dot */} - - - - - {step} - - {done && ( - {timeAgo(time)} - )} - - {note} - - - ))} - - - - )} - - ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - +import { useProjects } from "@/components/providers/project-provider"; + +/** + * Authenticated preview of *your* project's status page — this is what you, + * signed in, see for whichever project is active. It is NOT what a visitor + * sees; that's the public, session-free /status/[slug] route. This page just + * lets you check the page before/after sharing its public link. + */ export default function StatusPage() { + const { activeProject } = useProjects(); const { data: services } = useServices(); const { data: incidents } = useIncidents(); - const [email, setEmail] = useState(""); + // null until mounted — computing this during the initial render would + // differ between the server-rendered pass and the client hydration pass + // (a few ms apart) and trip a hydration mismatch. + const [lastChecked, setLastChecked] = useState(null); + + useEffect(() => { + setLastChecked(new Date().toLocaleTimeString()); + const id = setInterval(() => setLastChecked(new Date().toLocaleTimeString()), 30_000); + return () => clearInterval(id); + }, []); const hasDegraded = services.some((s) => s.currentStatus !== "operational"); - const overallKey: keyof typeof OVERALL_STATUS = hasDegraded ? "degraded" : "operational"; - const overall = OVERALL_STATUS[overallKey]; - const StatusIcon = overall.icon; return ( @@ -168,45 +40,55 @@ export default function StatusPage() { - Liveboard Status + Status preview — {activeProject?.name ?? "—"} - + Back to Dashboard → + {activeProject?.statusPageEnabled && activeProject.publicSlug ? ( + + View your public status page + + ) : ( + + This page isn't public yet — enable it from the project switcher to get a shareable link. + + )} + {/* Overall status */} - - - - {overall.label} - - Last checked: {new Date().toLocaleTimeString()} · Updates in real time - - - + {/* Services */} - + Components {services.map((service) => ( ))} + {services.length === 0 && ( + No traffic recorded yet — components appear here once your API receives requests. + )} {/* Incident history with timelines */} - + Incident History - + {incidents.filter((i) => !i.resolved).length} active @@ -218,29 +100,11 @@ export default function StatusPage() { {/* Subscribe */} - - - - Get Incident Alerts - - - Subscribe to receive email notifications for incidents and status updates. - - - setEmail(e.target.value)} - className="flex-1 bg-[#0D0D0D] border border-[#2A2A2A] rounded px-3 py-1.5 text-sm text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue transition-colors" - /> - - Subscribe - - - + {activeProject?.statusPageEnabled && activeProject.publicSlug ? ( + + ) : null} - ); diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx index 5c8672c..f8ca2d5 100644 --- a/components/auth/user-menu.tsx +++ b/components/auth/user-menu.tsx @@ -55,9 +55,9 @@ export function UserMenu() { className="flex items-center gap-2.5 px-2.5 py-1.5 rounded hover:bg-[#161616] transition-colors" > - ? + ? - Sign in + Sign in ); } @@ -71,7 +71,7 @@ export function UserMenu() { {name ?? "User"} - {email} + {email} @@ -86,7 +86,7 @@ export function UserMenu() { {/* User info header */} {name} - {email} + {email} diff --git a/components/charts/empty-chart-state.tsx b/components/charts/empty-chart-state.tsx new file mode 100644 index 0000000..39196a4 --- /dev/null +++ b/components/charts/empty-chart-state.tsx @@ -0,0 +1,12 @@ +import { BarChart3 } from "lucide-react"; + +/** Honest placeholder for a chart with no data — matches the 160px chart + * height so swapping between the two doesn't shift the surrounding layout. */ +export function EmptyChartState({ message }: { message: string }) { + return ( + + + {message} + + ); +} diff --git a/components/charts/error-rate-chart.tsx b/components/charts/error-rate-chart.tsx index 5240629..93a46ec 100644 --- a/components/charts/error-rate-chart.tsx +++ b/components/charts/error-rate-chart.tsx @@ -10,7 +10,7 @@ import { CartesianGrid, } from "recharts"; import type { ChartData } from "@/lib/types"; -import { formatNumber } from "@/lib/utils"; +import { formatNumber, formatChartTime } from "@/lib/utils"; interface ErrorRateChartProps { data: ChartData[]; @@ -32,7 +32,7 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) { if (!active || !payload?.length) return null; return ( - {label} + {label ? formatChartTime(label) : ""} {payload.map((p) => ( @@ -47,11 +47,14 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) { } export function ErrorRateChart({ data }: ErrorRateChartProps) { - const xStep = Math.ceil(data.length / 8); + // Labels now always carry the date ("07/20 14:30", not "14:30" — see + // apps/api/api/routes/query.py) so fewer of them fit before overlapping; + // target ~5 visible ticks instead of ~8. + const xStep = Math.ceil(data.length / 3); return ( - + @@ -69,13 +72,14 @@ export function ErrorRateChart({ data }: ErrorRateChartProps) { diff --git a/components/charts/requests-chart.tsx b/components/charts/requests-chart.tsx index 147b8ec..347a377 100644 --- a/components/charts/requests-chart.tsx +++ b/components/charts/requests-chart.tsx @@ -10,7 +10,7 @@ import { CartesianGrid, } from "recharts"; import type { ChartData } from "@/lib/types"; -import { formatNumber } from "@/lib/utils"; +import { formatNumber, formatChartTime } from "@/lib/utils"; interface RequestsChartProps { data: ChartData[]; @@ -30,7 +30,7 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) { if (!active || !payload?.length) return null; return ( - {label} + {label ? formatChartTime(label) : ""} {formatNumber(payload[0].value)} req/min @@ -39,11 +39,14 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) { } export function RequestsChart({ data }: RequestsChartProps) { - const xStep = Math.ceil(data.length / 8); + // Labels now always carry the date ("07/20 14:30", not "14:30" — see + // apps/api/api/routes/query.py) so fewer of them fit before overlapping; + // target ~5 visible ticks instead of ~8. + const xStep = Math.ceil(data.length / 3); return ( - + @@ -53,13 +56,14 @@ export function RequestsChart({ data }: RequestsChartProps) { - - Active Pipeline Architecture - - - Edit Pipeline - - - - {/* 1. INGRESS */} - - - 1. INGRESS (ENTRY POINT) - - - {[ - { label: "Endpoint:", value: "/v1/chat/completions", badge: "POST" }, - { label: "Auth Protocol:", value: "Bearer Token (Strict)" }, - { label: "Rate Limit:", value: "req/min per IP", badge: "100" }, - ].map((row) => ( - - {row.label} - - {row.value} - {row.badge && ( - {row.badge} - )} - - - ))} - - - - {/* 2. PRE-PROCESSING */} - - - 2. PRE-PROCESSING (MIDDLEWARE) - - - {[ - { - title: "PII Redact", - icon: Shield, - fields: [{ k: "Mode:", v: "MASK" }, { k: "Engine:", v: "NER" }], - }, - { - title: "Injection", - icon: GitMerge, - fields: [{ k: "Heuristic:", v: "ON", green: true }, { k: "Vector:", v: "STRICT" }], - }, - { - title: "Toxicity Class.", - icon: Zap, - fields: [{ k: "Threshold:", v: "0.85" }, { k: "Action:", v: "DROP", red: true }], - }, - ].map((block) => ( - - - - {block.title} - - {block.fields.map((f) => ( - - {f.k} - - {f.v} - - - ))} - - ))} - - - - {/* 3. DYNAMIC ROUTER */} - - - 3. DYNAMIC ROUTER (DECISION MATRIX) - - - {[ - { - num: "01", - severity: "High", - condition: ['IF', '[', 'Intent', ']', '==', '[', 'Billing', ',', 'Refund', ']'], - route: "GPT-4o", - temp: "0.2", - maxTokens: "1024", - badge: "red" as const, - }, - { - num: "02", - severity: "Medium", - condition: ['IF', '[', 'Intent', ']', '==', '[', 'General', ',', 'FAQ', ']'], - route: "Claude Sonnet", - temp: "0.7", - maxTokens: "2048", - badge: "yellow" as const, - }, - ].map((rule) => ( - - - - Rule {rule.num} - {rule.severity} - - - - - {rule.condition.map((token, i) => ( - - {token} - - ))} - - - {[ - { label: "Route to:", value: rule.route }, - { label: "Temp:", value: rule.temp }, - { label: "Max Tokens:", value: rule.maxTokens }, - ].map((f) => ( - - {f.label} - {f.value} - - ))} - - - ))} - - - - ); -} diff --git a/components/dashboard/incident-panel.tsx b/components/dashboard/incident-panel.tsx index 715d8d8..03d4471 100644 --- a/components/dashboard/incident-panel.tsx +++ b/components/dashboard/incident-panel.tsx @@ -51,12 +51,12 @@ function IncidentCard({ incident }: { incident: Incident }) { )} {incident.title} - {timeAgo(incident.timestamp)} + {timeAgo(incident.timestamp)} {expanded ? ( - + ) : ( - + )} @@ -72,7 +72,7 @@ function IncidentCard({ incident }: { incident: Incident }) { {incident.summary} - Affected: + Affected: {incident.endpoint} @@ -104,7 +104,7 @@ export function IncidentPanel({ incidents }: IncidentPanelProps) { ))} {resolved.length > 0 && ( <> - Resolved + Resolved {resolved.map((i) => ( ))} diff --git a/components/dashboard/live-log-table.tsx b/components/dashboard/live-log-table.tsx index 133995d..3470c68 100644 --- a/components/dashboard/live-log-table.tsx +++ b/components/dashboard/live-log-table.tsx @@ -1,28 +1,39 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Pause, Play } from "lucide-react"; +import { Loader2, Pause, Play, WifiOff } from "lucide-react"; import { useLiveLog } from "@/hooks/use-live-metrics"; import { getMethodColor, getStatusColor, formatMs } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const STATUS_DOT = { + "no-project": "bg-[#555]", + connecting: "bg-yellow animate-pulse", + reconnecting: "bg-yellow animate-pulse", + live: "bg-green animate-pulse", +} as const; + +const STATUS_LABEL = { + "no-project": "No project", + connecting: "Connecting…", + reconnecting: "Reconnecting…", + live: "Live Request Log", +} as const; export function LiveLogTable() { - const { logs, paused, setPaused } = useLiveLog(40); + const { logs, paused, setPaused, status } = useLiveLog(40); return ( - + - Live Request Log + {status === "live" ? "Live Request Log" : STATUS_LABEL[status]} - setPaused(!paused)} - > + setPaused(!paused)} disabled={status !== "live"}> {paused ? ( <> Resume> ) : ( @@ -34,7 +45,7 @@ export function LiveLogTable() { {/* Table header */} {["Time", "Method", "Route", "Status", "Latency", "User"].map((h) => ( - + {h} ))} @@ -42,47 +53,72 @@ export function LiveLogTable() { {/* Rows */} - - {logs.map((log) => ( - - - {log.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} - - - + {status === "no-project" ? ( + <> + + Select a project to see its live requests. + > + ) : status === "connecting" ? ( + <> + + Connecting to the live stream… + > + ) : status === "reconnecting" ? ( + <> + + Connection dropped — reconnecting… + > + ) : ( + + No requests yet — they'll show up here as soon as your API gets traffic. + + )} + + ) : ( + + {logs.map((log) => ( + - {log.method} - + + {log.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + - - {log.route} - + + {log.method} + - - {log.status} - + + {log.route} + + + + {log.status} + - - {formatMs(log.latency)} - + + {formatMs(log.latency)} + - - {log.userId} - - - ))} - + + {log.userId} + + + ))} + + )} ); diff --git a/components/dashboard/pipeline-architecture.tsx b/components/dashboard/pipeline-architecture.tsx deleted file mode 100644 index 8e3262c..0000000 --- a/components/dashboard/pipeline-architecture.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { Shield } from "lucide-react"; - -interface PipelineNode { - id: string; - label: string; - sublabel?: string; - status: "healthy" | "warning" | "error"; - traffic?: number; - latency?: number; -} - -const NODES: PipelineNode[] = [ - { id: "gateway", label: "API Gateway", sublabel: "/v1/chat/completions", status: "healthy", traffic: 100 }, - { id: "gpt4o", label: "GPT-4o", status: "healthy", traffic: 68, latency: 120 }, - { id: "claude", label: "Claude Sonnet", status: "healthy", traffic: 22, latency: 145 }, - { id: "gemini", label: "Gemini Flash", status: "warning", traffic: 10, latency: 850 }, -]; - -const GUARDRAIL_ROWS = [ - { time: "10:42 AM", client: "user_892x", rule: "PII_Detector_v2 (Credit Card Info)", prompt: '"payment with 4532 1121"', action: "Blocked" as const }, - { time: "10:15 AM", client: "user_110a", rule: "Prompt Injection Attempt", prompt: '"Ignore previous instructions"', action: "Fallback" as const }, - { time: "09:30 AM", client: "sys_router", rule: "Upstream Timeout (GPT-4o > 5000ms)", prompt: '"Summarize the attached 50…"', action: "Blocked" as const }, - { time: "09:12 AM", client: "user_4480", rule: "Output Parsing: Invalid JSON Schema", prompt: "user_name: John Doe...", action: "Retried" as const }, - { time: "08:45 AM", client: "anon_io", rule: "Rate Limit: 50 requests / minute", prompt: '"What is the capital of E…"', action: "Fallback" as const }, -]; - -const ACTION_VARIANTS = { - Blocked: "red" as const, - Fallback: "yellow" as const, - Retried: "blue" as const, -}; - -export function PipelineArchitecture() { - return ( - - {/* Flow diagram */} - - - Endpoint Traffic & Health - - - Live - - - - {/* Gateway node */} - - - API Gateway - /v1/chat/completions - 124k Reqs - - - {/* Connectors */} - - {NODES.slice(1).map((node) => ( - - - - - {node.label} - - {node.status === "healthy" ? "Healthy" : "Warning"} - - - - Traffic - {node.traffic}% - - - Avg - 500 ? "#F59E0B" : "#888" }}> - {node.latency}ms - - - - - ))} - - - - - {/* Guardrail exceptions */} - - - - - Guardrail Exceptions - - - Filter - - - - - {["Time", "Client ID", "Violation / Rule", "Prompt Snippet", "Action"].map((h) => ( - {h} - ))} - - - - {GUARDRAIL_ROWS.map((row, i) => ( - - {row.time} - {row.client} - {row.rule} - {row.prompt} - {row.action} - - ))} - - - - ); -} diff --git a/components/dashboard/stat-card.tsx b/components/dashboard/stat-card.tsx index bca6b38..96513dc 100644 --- a/components/dashboard/stat-card.tsx +++ b/components/dashboard/stat-card.tsx @@ -20,7 +20,7 @@ export function StatCardComponent({ card, sparkColor = "#378ADD" }: StatCardProp - + {card.label} @@ -28,7 +28,7 @@ export function StatCardComponent({ card, sparkColor = "#378ADD" }: StatCardProp {card.value} {card.unit && ( - {card.unit} + {card.unit} )} @@ -54,7 +54,7 @@ export function StatCardComponent({ card, sparkColor = "#378ADD" }: StatCardProp {card.unit === "%" ? "pp" : card.unit === "ms" ? "ms" : "%"} - {card.deltaLabel} + {card.deltaLabel} ); diff --git a/components/endpoints/endpoint-comparison.tsx b/components/endpoints/endpoint-comparison.tsx index 8ac5593..823c882 100644 --- a/components/endpoints/endpoint-comparison.tsx +++ b/components/endpoints/endpoint-comparison.tsx @@ -77,7 +77,7 @@ export function EndpointComparison({ endpoints, onClose }: EndpointComparisonPro ))} - + @@ -86,10 +86,10 @@ export function EndpointComparison({ endpoints, onClose }: EndpointComparisonPro {metrics.map((m) => ( - {m.label} + {m.label} - A + A - B + B - p99 Latency — 24h + p99 Latency — 24h - - `${v}ms`} width={45} /> + + `${v}ms`} width={45} /> `${String(v)}ms`} /> - + diff --git a/components/endpoints/endpoint-drawer.tsx b/components/endpoints/endpoint-drawer.tsx index 15d2b0d..d303293 100644 --- a/components/endpoints/endpoint-drawer.tsx +++ b/components/endpoints/endpoint-drawer.tsx @@ -41,7 +41,7 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { @@ -51,7 +51,7 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { {/* Health Score */} - Health Score + Health Score ( - {stat.label} + {stat.label} {stat.value} @@ -91,7 +91,7 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { {/* Latency */} - Latency Distribution + Latency Distribution {[ @@ -100,7 +100,7 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { { label: "p99", value: formatMs(endpoint.p99), color: "#EF4444" }, ].map((p) => ( - {p.label} + {p.label} {p.value} @@ -111,7 +111,7 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { {/* Top errors */} - Top Errors + Top Errors {endpoint.errorRate > 0 ? ( {[ @@ -120,13 +120,13 @@ export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) { ].map((err, i) => ( {err.code} - {err.msg} - {err.count}x + {err.msg} + {err.count}x ))} ) : ( - No errors in selected window + No errors in selected window )} diff --git a/components/endpoints/endpoint-table.tsx b/components/endpoints/endpoint-table.tsx index 1a29f90..b37551e 100644 --- a/components/endpoints/endpoint-table.tsx +++ b/components/endpoints/endpoint-table.tsx @@ -30,7 +30,7 @@ export function EndpointTable({ endpoints, onSelect, selected, compareSelected = const SortBtn = ({ k, label }: { k: SortKey; label: string }) => ( toggleSort(k)} > {label} @@ -42,13 +42,13 @@ export function EndpointTable({ endpoints, onSelect, selected, compareSelected = {/* Header */} - Endpoint + Endpoint - Health + Health @@ -100,9 +100,14 @@ export function EndpointTable({ endpoints, onSelect, selected, compareSelected = - + ))} + {sorted.length === 0 && ( + + No endpoints have received traffic in this range yet. + + )} ); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 0a0a5a7..fbd4ee3 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -9,7 +9,6 @@ import { Bell, Globe, Settings, - Activity, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LiveboardIcon } from "@/components/logo"; @@ -20,7 +19,6 @@ const NAV_ITEMS = [ { href: "/overview", label: "Overview", icon: LayoutDashboard }, { href: "/endpoints", label: "Endpoints", icon: Zap }, { href: "/traces", label: "Traces", icon: GitBranch }, - { href: "/pipelines", label: "Pipelines", icon: Activity }, { href: "/alerts", label: "Alerts", icon: Bell }, { href: "/status", label: "Status Page", icon: Globe }, ]; @@ -74,7 +72,12 @@ export function Sidebar() { Settings diff --git a/components/layout/time-range-picker.tsx b/components/layout/time-range-picker.tsx index bf3f34a..a6f90d0 100644 --- a/components/layout/time-range-picker.tsx +++ b/components/layout/time-range-picker.tsx @@ -6,13 +6,35 @@ import type { TimeRange } from "@/lib/types"; interface TimeRangePickerProps { value: TimeRange; onChange: (range: TimeRange) => void; + /** Include the "Live" option — only meaningful where the caller actually + * has a realtime data source wired up (e.g. Overview's socket-fed chart). + * Off by default so REST-only consumers (like Endpoints) don't show a + * button that quietly does nothing. */ + showLive?: boolean; } const RANGES: TimeRange[] = ["1h", "6h", "24h", "7d"]; -export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) { +export function TimeRangePicker({ value, onChange, showLive = false }: TimeRangePickerProps) { return ( + {showLive && ( + onChange("live")} + className={cn( + "flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-all duration-100 font-medium", + value === "live" + ? "bg-blue text-white" + : "text-[#949494] hover:text-[#888]" + )} + > + + Live + + )} {RANGES.map((range) => ( {range} diff --git a/components/layout/topbar.tsx b/components/layout/topbar.tsx index c5a1904..6ef2487 100644 --- a/components/layout/topbar.tsx +++ b/components/layout/topbar.tsx @@ -20,12 +20,12 @@ export function Topbar({ breadcrumb, actions }: TopbarProps) { {breadcrumb?.map((crumb, i) => ( - {i > 0 && /} + {i > 0 && /} {crumb.label} @@ -36,19 +36,19 @@ export function Topbar({ breadcrumb, actions }: TopbarProps) { {/* Search */} - + - ⌘K + ⌘K {/* Actions */} {actions} - + diff --git a/components/projects/create-project-dialog.tsx b/components/projects/create-project-dialog.tsx index 8c43862..5f90911 100644 --- a/components/projects/create-project-dialog.tsx +++ b/components/projects/create-project-dialog.tsx @@ -82,7 +82,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { @@ -94,7 +94,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { Create project - + A project groups API keys, events, and alert rules. @@ -136,7 +136,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { API key created - + Copy your API key now — it will not be shown again. @@ -144,7 +144,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { - + {result.project.name} @@ -153,7 +153,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { {copied ? ( @@ -173,10 +173,10 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) { - Quick start + Quick start - npm install liveboard - LIVEBOARD_API_KEY={result.project.apiKeyMasked} + npm install liveboard + LIVEBOARD_API_KEY={result.project.apiKeyMasked} diff --git a/components/projects/project-switcher.tsx b/components/projects/project-switcher.tsx index da9626f..938877e 100644 --- a/components/projects/project-switcher.tsx +++ b/components/projects/project-switcher.tsx @@ -1,7 +1,8 @@ "use client"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { Check, ChevronDown, Plus } from "lucide-react"; +import { Check, ChevronDown, Plus, Settings } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; import { useProjects, type Project } from "@/components/providers/project-provider"; import { CreateProjectDialog } from "@/components/projects/create-project-dialog"; @@ -44,7 +45,7 @@ export function ProjectSwitcher() { {activeProject?.name ?? "Select project"} - + @@ -55,7 +56,7 @@ export function ProjectSwitcher() { align="start" sideOffset={4} > - + Projects @@ -75,6 +76,19 @@ export function ProjectSwitcher() { + {activeProject && ( + + setDropdownOpen(false)} + className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-[#888] hover:text-[#F5F5F5] hover:bg-[#1A1A1A] outline-none cursor-pointer transition-colors" + > + + Project settings + + + )} + Promise; deleteProject: (id: string) => Promise; rotateKey: (id: string) => Promise; + setStatusPageEnabled: (id: string, enabled: boolean) => Promise<{ enabled: boolean; publicSlug: string | null }>; refresh: () => Promise; } @@ -43,10 +46,19 @@ interface ProjectDetailDTO { org_id: string; api_key_masked: string; created_at: string; + status_page_enabled: boolean; + public_slug: string | null; } function fromDTO(d: ProjectDetailDTO): Project { - return { id: d.id, name: d.name, apiKeyMasked: d.api_key_masked, createdAt: d.created_at }; + return { + id: d.id, + name: d.name, + apiKeyMasked: d.api_key_masked, + createdAt: d.created_at, + statusPageEnabled: d.status_page_enabled, + publicSlug: d.public_slug, + }; } function maskRaw(raw: string): string { @@ -131,6 +143,8 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) { name: data.project.name, apiKeyMasked: maskRaw(data.new_api_key), createdAt: new Date().toISOString(), + statusPageEnabled: false, + publicSlug: null, }; return { project, rawApiKey: data.new_api_key }; }, @@ -155,6 +169,21 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) { return data.new_api_key; }, [refresh]); + const setStatusPageEnabled = useCallback( + async (id: string, enabled: boolean): Promise<{ enabled: boolean; publicSlug: string | null }> => { + const res = await fetch(`/api/projects/${id}/status-page`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error("Failed to update status page setting"); + const data = (await res.json()) as { enabled: boolean; public_slug: string | null }; + await refresh(); + return { enabled: data.enabled, publicSlug: data.public_slug }; + }, + [refresh] + ); + const activeProject = projects.find((p) => p.id === activeProjectId) ?? projects[0] ?? null; @@ -168,6 +197,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) { createProject, deleteProject, rotateKey, + setStatusPageEnabled, refresh, }} > diff --git a/components/settings/project-settings-card.tsx b/components/settings/project-settings-card.tsx new file mode 100644 index 0000000..cc2ba81 --- /dev/null +++ b/components/settings/project-settings-card.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, ExternalLink, RefreshCw, Trash2 } from "lucide-react"; +import { useProjects, type Project } from "@/components/providers/project-provider"; +import { Button } from "@/components/ui/button"; +import { cn, timeAgo } from "@/lib/utils"; + +interface Props { + project: Project; +} + +/** + * One project's real settings: API key + rotate, public status page toggle, + * and a destructive delete — all backed by the existing ProjectProvider + * actions (rotateKey/setStatusPageEnabled/deleteProject), which were already + * fully wired to the backend but had no UI caller anywhere in the app. + */ +export function ProjectSettingsCard({ project }: Props) { + const { rotateKey, deleteProject, setStatusPageEnabled } = useProjects(); + + const [rotateConfirm, setRotateConfirm] = useState(false); + const [rotating, setRotating] = useState(false); + const [newKey, setNewKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + + const [statusSaving, setStatusSaving] = useState(false); + const [statusError, setStatusError] = useState(""); + const [copiedLink, setCopiedLink] = useState(false); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(""); + + async function handleRotate() { + setRotating(true); + try { + const raw = await rotateKey(project.id); + setNewKey(raw); + setRotateConfirm(false); + } catch { + // leave rotateConfirm open so the user can retry + } finally { + setRotating(false); + } + } + + function copyNewKey() { + if (!newKey) return; + navigator.clipboard.writeText(newKey).then(() => { + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + }); + } + + async function handleToggleStatusPage() { + setStatusSaving(true); + setStatusError(""); + try { + await setStatusPageEnabled(project.id, !project.statusPageEnabled); + } catch { + setStatusError("Couldn't update the status page. Please try again."); + } finally { + setStatusSaving(false); + } + } + + const publicUrl = project.publicSlug + ? `${typeof window !== "undefined" ? window.location.origin : ""}/status/${project.publicSlug}` + : null; + + function copyLink() { + if (!publicUrl) return; + navigator.clipboard.writeText(publicUrl).then(() => { + setCopiedLink(true); + setTimeout(() => setCopiedLink(false), 2000); + }); + } + + async function handleDelete() { + setDeleting(true); + setDeleteError(""); + try { + await deleteProject(project.id); + // success: this project disappears from the list and the card unmounts + } catch { + setDeleteError("Couldn't delete the project. Please try again."); + setDeleting(false); + } + } + + return ( + + + {project.name} + Created {timeAgo(new Date(project.createdAt))} + + + + {/* API key */} + + API key + {newKey ? ( + + New key generated — copy it now, it won't be shown again. + + {newKey} + + {copiedKey ? : } + + + + ) : ( + + + {project.apiKeyMasked} + + {rotateConfirm ? ( + <> + + {rotating ? "Rotating…" : "Confirm rotate"} + + setRotateConfirm(false)} disabled={rotating}> + Cancel + + > + ) : ( + setRotateConfirm(true)}> + Rotate + + )} + + )} + {rotateConfirm && !newKey && ( + + This immediately invalidates the current key — anything using it stops working until updated. + + )} + + + {/* Public status page */} + + + + Public status page + + Anyone with the link can view it — no account needed. + + + + + + + + {project.statusPageEnabled && publicUrl && ( + + + + {copiedLink ? : } + + + + + + )} + {statusError && {statusError}} + + + {/* Danger zone */} + + Danger zone + {!deleteOpen ? ( + setDeleteOpen(true)}> + Delete project + + ) : ( + + + This permanently deletes {project.name} and + all its events, traces, alert rules, and API keys. Type the project name to confirm. + + setDeleteConfirmText(e.target.value)} + placeholder={project.name} + className="w-full bg-[#0D0D0D] border border-red/30 rounded px-2.5 py-1.5 text-xs text-[#F5F5F5] placeholder-[#555] outline-none focus:border-red/60 transition-colors" + /> + + + {deleting ? "Deleting…" : "Delete permanently"} + + { setDeleteOpen(false); setDeleteConfirmText(""); }} + disabled={deleting} + > + Cancel + + + {deleteError && {deleteError}} + + )} + + + + ); +} diff --git a/components/status/auto-refresh.tsx b/components/status/auto-refresh.tsx new file mode 100644 index 0000000..9dd7088 --- /dev/null +++ b/components/status/auto-refresh.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** Periodically re-runs the current route's Server Component with fresh data + * (no client-side fetch, no session) — backs the "updates in real time" claim + * on the public status page. */ +export function AutoRefresh({ intervalMs = 30_000 }: { intervalMs?: number }) { + const router = useRouter(); + useEffect(() => { + const id = setInterval(() => router.refresh(), intervalMs); + return () => clearInterval(id); + }, [router, intervalMs]); + return null; +} diff --git a/components/status/incident-card.tsx b/components/status/incident-card.tsx new file mode 100644 index 0000000..2622928 --- /dev/null +++ b/components/status/incident-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { ChevronDown } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn, timeAgo } from "@/lib/utils"; +import type { Incident } from "@/lib/types"; + +type TimelineStep = "investigating" | "identified" | "monitoring" | "resolved"; + +const STEP_COLORS: Record = { + investigating: "#EF4444", + identified: "#F59E0B", + monitoring: "#378ADD", + resolved: "#22C55E", +}; + +const STEP_ORDER: TimelineStep[] = ["investigating", "identified", "monitoring", "resolved"]; + +function buildTimeline(incident: Incident): { step: TimelineStep; time: Date; note: string; done: boolean }[] { + const base = incident.timestamp.getTime(); + const steps: TimelineStep[] = incident.resolved + ? ["investigating", "identified", "monitoring", "resolved"] + : ["investigating", "identified"]; + + const offsets = [0, 8 * 60000, 22 * 60000, 35 * 60000]; + const notes: Record = { + investigating: "Incident detected and engineers paged.", + identified: `Root cause identified: elevated error rate on ${incident.endpoint}.`, + monitoring: "Fix deployed, monitoring for stability.", + resolved: "All systems back to normal. Post-mortem scheduled.", + }; + + return STEP_ORDER.map((step, i) => ({ + step, + time: new Date(base + offsets[i]!), + note: notes[step], + done: steps.includes(step), + })); +} + +/** Incident card with an expandable status timeline. Shared by the authed + * status preview and the public /status/[slug] page — no hooks beyond local + * expand/collapse state, so it renders the same either side of auth. */ +export function IncidentCard({ incident }: { incident: Incident }) { + const [expanded, setExpanded] = useState(!incident.resolved); + const timeline = useMemo(() => buildTimeline(incident), [incident]); + const currentStep = incident.resolved ? "resolved" : timeline.filter((t) => t.done).slice(-1)[0]?.step ?? "investigating"; + + return ( + + {/* Header */} + setExpanded((v) => !v)} + className="w-full p-4 text-left hover:bg-[#151515] transition-colors" + > + + + + {incident.severity} + + {incident.title} + + + + {incident.resolved ? "Resolved" : currentStep} + + + + + {incident.summary} + + {timeAgo(incident.timestamp)} + · + {incident.endpoint} + + + + {/* Timeline */} + {expanded && ( + + Timeline + + {/* Vertical connector */} + + + + {timeline.map(({ step, time, note, done }) => ( + + {/* Step dot */} + + + + + {step} + + {done && ( + {timeAgo(time)} + )} + + {note} + + + ))} + + + + )} + + ); +} diff --git a/components/status/overall-banner.tsx b/components/status/overall-banner.tsx new file mode 100644 index 0000000..6e1299c --- /dev/null +++ b/components/status/overall-banner.tsx @@ -0,0 +1,42 @@ +import { CheckCircle2, AlertTriangle, XCircle } from "lucide-react"; + +const OVERALL_STATUS = { + operational: { + label: "All Systems Operational", + color: "text-green", + bg: "bg-green/10 border-green/20", + icon: CheckCircle2, + }, + degraded: { + label: "Degraded Performance", + color: "text-yellow", + bg: "bg-yellow/10 border-yellow/20", + icon: AlertTriangle, + }, + partial_outage: { + label: "Partial Outage", + color: "text-red", + bg: "bg-red/10 border-red/20", + icon: XCircle, + }, +}; + +/** Top status banner — shared by the authed status preview and the public + * /status/[slug] page. Server-renderable: no hooks, no client state. */ +export function OverallBanner({ hasDegraded, lastChecked }: { hasDegraded: boolean; lastChecked: string | null }) { + const key: keyof typeof OVERALL_STATUS = hasDegraded ? "degraded" : "operational"; + const overall = OVERALL_STATUS[key]; + const StatusIcon = overall.icon; + + return ( + + + + {overall.label} + + {lastChecked ? `Last checked: ${lastChecked} · ` : ""}Updates in real time + + + + ); +} diff --git a/components/status/subscribe-form.tsx b/components/status/subscribe-form.tsx new file mode 100644 index 0000000..df547d7 --- /dev/null +++ b/components/status/subscribe-form.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +/** + * Public, unauthenticated subscribe form — posts straight to the FastAPI + * public-status endpoint (CORS-allowed for this origin) rather than the + * session-gated BFF, since a status-page visitor never has a session. + * Shared by the public /status/[slug] page and the owner's authed preview. + */ +export function SubscribeForm({ slug }: { slug: string }) { + const [email, setEmail] = useState(""); + const [state, setState] = useState<"idle" | "sending" | "sent" | "error">("idle"); + + async function handleSubscribe() { + if (!email.trim()) return; + setState("sending"); + try { + const res = await fetch(`${API_URL}/v1/public/status/${encodeURIComponent(slug)}/subscribe`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: email.trim() }), + }); + if (!res.ok) throw new Error(String(res.status)); + setState("sent"); + } catch { + setState("error"); + } + } + + return ( + + + + Get Incident Alerts + + + Subscribe to receive email notifications for incidents and status updates. + + + {state === "sent" ? ( + Check your email to confirm the subscription. + ) : ( + + setEmail(e.target.value)} + disabled={state === "sending"} + className="flex-1 bg-[#0D0D0D] border border-[#2A2A2A] rounded px-3 py-1.5 text-sm text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue transition-colors" + /> + + {state === "sending" ? "Subscribing…" : "Subscribe"} + + + )} + {state === "error" && ( + Something went wrong — try again. + )} + + ); +} diff --git a/components/status/uptime-bar.tsx b/components/status/uptime-bar.tsx index 1d48907..42781a0 100644 --- a/components/status/uptime-bar.tsx +++ b/components/status/uptime-bar.tsx @@ -48,7 +48,7 @@ export function UptimeBar({ service }: UptimeBarProps) { {service.uptime90d.toFixed(2)}% - uptime (90d) + uptime (90d) @@ -67,8 +67,8 @@ export function UptimeBar({ service }: UptimeBarProps) { ))} - 90 days ago - Today + 90 days ago + Today ); diff --git a/components/traces/flame-graph.tsx b/components/traces/flame-graph.tsx index 66fd194..2c416c2 100644 --- a/components/traces/flame-graph.tsx +++ b/components/traces/flame-graph.tsx @@ -67,11 +67,11 @@ export function FlameGraph({ trace, onSpanClick, selectedSpan, highlightCritical {/* Header */} - trace_id: + trace_id: {trace.id} - {trace.spans.length} spans + {trace.spans.length} spans {[0, 0.25, 0.5, 0.75, 1].map((pct) => ( - + {Math.round(pct * totalDuration)}ms ))} @@ -108,7 +108,7 @@ export function FlameGraph({ trace, onSpanClick, selectedSpan, highlightCritical {span.service} @@ -134,7 +134,7 @@ export function FlameGraph({ trace, onSpanClick, selectedSpan, highlightCritical )} - + {span.duration}ms diff --git a/components/traces/service-map.tsx b/components/traces/service-map.tsx index 7e12959..cf9ee60 100644 --- a/components/traces/service-map.tsx +++ b/components/traces/service-map.tsx @@ -107,7 +107,7 @@ export function ServiceMap({ trace }: ServiceMapProps) { return ( - Service Map + Service Map @@ -136,7 +136,7 @@ export function ServiceMap({ trace }: ServiceMapProps) { y={(fromNode.y + toNode.y) / 2 - 6} textAnchor="middle" fontSize={8} - fill="#333" + fill="#808080" className="font-mono" > ×{edge.count} @@ -205,7 +205,7 @@ export function ServiceMap({ trace }: ServiceMapProps) { return ( - {node.service} + {node.service} {node.hasError && !} ); diff --git a/components/traces/span-detail.tsx b/components/traces/span-detail.tsx index e97bbe0..9586965 100644 --- a/components/traces/span-detail.tsx +++ b/components/traces/span-detail.tsx @@ -40,7 +40,7 @@ export function SpanDetail({ span, onClose }: SpanDetailProps) { @@ -56,7 +56,7 @@ export function SpanDetail({ span, onClose }: SpanDetailProps) { { label: "Parent", value: span.parentId || "root", mono: true }, ].map((field) => ( - {field.label} + {field.label} 0 && ( - Tags + Tags {Object.entries(span.tags).map(([k, v]) => ( {k} - = + = {v} ))} diff --git a/hooks/use-api-query.ts b/hooks/use-api-query.ts index ca4d2c6..d7e3459 100644 --- a/hooks/use-api-query.ts +++ b/hooks/use-api-query.ts @@ -4,22 +4,26 @@ import { useEffect, useRef, useState } from "react"; import { apiFetch } from "@/lib/api-client"; import { useProjects } from "@/components/providers/project-provider"; -// Stable empty-array reference for the "real project, no data" state — reused -// across renders so consumers depending on the result don't see a new [] each -// render (which would loop any effect keyed on it). -const EMPTY_LIST: readonly never[] = []; +// Stable empty-array reference — reused across renders so consumers depending +// on the result don't see a new [] each render (which would loop any effect +// keyed on it). Exported so callers can pass the same reference as `empty`. +export const EMPTY_LIST: readonly never[] = []; /** * Generic hook for REST API data. - * - Shows `fallback` (mock) while loading or on error. - * - Swaps to real data once the fetch succeeds. + * + * Never fabricates data. `data` is `empty` while loading, with no active + * project, or on a fetch error — real data only ever comes from a real, + * successful fetch. Callers render their own honest empty/loading state from + * `loading` + an empty `data`, rather than this hook ever guessing at numbers. + * * - Refetches whenever `path` or serialised `params` change. * - Polls every `pollMs` milliseconds if provided. */ export function useApiQuery( path: string, params: Record, - fallback: T, + empty: T, transform?: (raw: unknown) => T, pollMs?: number, ): { data: T; loading: boolean; refetch: () => void } { @@ -44,13 +48,14 @@ export function useApiQuery( // Main fetch — runs on mount, param change, or manual refetch tick useEffect(() => { let active = true; - // Only clear data (show fallback) when params actually changed, not on poll ticks + // Only clear data (show empty) when params actually changed, not on poll ticks if (tick === 0) { setData(null); setLoading(true); } - // No active project yet → show fallback (demo) instead of a scopeless call. + // No active project yet (still loading auth/project state) — nothing to + // fetch. Stay on the honest empty state rather than a scopeless call. if (!projectId) { setLoading(false); return () => { @@ -92,13 +97,5 @@ export function useApiQuery( return () => clearInterval(id); }, [pollMs]); - // Honesty rule: a REAL project never shows the demo `fallback`. While loading - // or on error it shows an empty result (empty state), not fabricated data. - // The demo `fallback` is only used when there's no project (landing / loading). - // Use the shared EMPTY_LIST so the reference is stable across renders. - const effectiveFallback = projectId - ? (Array.isArray(fallback) ? (EMPTY_LIST as unknown as T) : fallback) - : fallback; - - return { data: data ?? effectiveFallback, loading, refetch }; + return { data: data ?? empty, loading, refetch }; } diff --git a/hooks/use-data.ts b/hooks/use-data.ts index 316123e..c7dedad 100644 --- a/hooks/use-data.ts +++ b/hooks/use-data.ts @@ -5,21 +5,13 @@ * Each hook: * 1. Fetches real data from the API. * 2. Transforms snake_case backend response to camelCase frontend types. - * 3. Falls back to mock data while loading or if the API is unreachable. + * 3. Shows an honest empty result while loading or if the API is + * unreachable — never fabricated data (see EMPTY_LIST in use-api-query). */ -import { useMemo } from "react"; -import { useApiQuery } from "@/hooks/use-api-query"; +import { useApiQuery, EMPTY_LIST } from "@/hooks/use-api-query"; import { apiMutate } from "@/lib/api-client"; import { useProjects } from "@/components/providers/project-provider"; -import { - generateChartData, - getAlertRules, - getEndpoints, - getIncidents, - getServiceStatuses, - getTraces, -} from "@/lib/mock-data"; import type { AlertRule, ChartData, @@ -156,11 +148,10 @@ function toServices(raw: unknown): ServiceStatus[] { /** Request-volume and status-code breakdown for the overview charts. Polls every 30 s. */ export function useChartData(hours: number) { - const fallback = useMemo(() => generateChartData(hours), [hours]); return useApiQuery( "/v1/metrics/timeseries", { hours: String(hours) }, - fallback, + EMPTY_LIST as unknown as ChartData[], undefined, 30_000, ); @@ -168,36 +159,32 @@ export function useChartData(hours: number) { /** AI-detected incidents — polls every 60 s (matches anomaly worker cadence). */ export function useIncidents() { - const fallback = useMemo(() => getIncidents(), []); - return useApiQuery("/v1/incidents", {}, fallback, toIncidents, 60_000); + return useApiQuery("/v1/incidents", {}, EMPTY_LIST as unknown as Incident[], toIncidents, 60_000); } /** Per-route aggregate stats for the endpoints explorer. */ export function useEndpoints(hours = 24) { - const fallback = useMemo(() => getEndpoints(), []); return useApiQuery( "/v1/endpoints", { hours: String(hours) }, - fallback, + EMPTY_LIST as unknown as Endpoint[], toEndpoints, ); } -/** - * Distributed traces from the spans table. - * Falls back to mock data while spans table is empty (pre-Phase 6). - */ -export function useTraces() { - const mockTraces = useMemo(() => getTraces(), []); - // Real project → shows real spans (empty when none). Only the no-project - // (landing) case falls back to the demo traces, handled inside useApiQuery. - return useApiQuery("/v1/traces", {}, mockTraces, toTraces); +/** Distributed traces from the spans table. */ +export function useTraces(hours = 24) { + return useApiQuery( + "/v1/traces", + { hours: String(hours) }, + EMPTY_LIST as unknown as Trace[], + toTraces, + ); } /** Service health derived from route-prefix groups — used by the status page. */ export function useServices() { - const fallback = useMemo(() => getServiceStatuses(), []); - return useApiQuery("/v1/services", {}, fallback, toServices); + return useApiQuery("/v1/services", {}, EMPTY_LIST as unknown as ServiceStatus[], toServices, 30_000); } // ─── Alert rules ───────────────────────────────────────────────────────────── @@ -282,9 +269,10 @@ function toAlertHistory(raw: unknown): AlertHistoryEntry[] { export function useAlertRules() { const { activeProject } = useProjects(); const project = activeProject?.id; - const fallback = useMemo(() => getAlertRules(), []); // Poll so a rule flipping to "firing" (by the evaluation worker) shows up live. - const query = useApiQuery("/v1/alert-rules", {}, fallback, toAlertRules, 15_000); + const query = useApiQuery( + "/v1/alert-rules", {}, EMPTY_LIST as unknown as AlertRule[], toAlertRules, 15_000, + ); async function createRule(payload: CreateAlertRulePayload): Promise { const raw = await apiMutate("POST", "/v1/alert-rules", payload, project); @@ -304,8 +292,9 @@ export function useAlertRules() { /** Alert firing history for the current project. */ export function useAlertHistory() { - const fallback = useMemo((): AlertHistoryEntry[] => [], []); - return useApiQuery("/v1/alert-history", {}, fallback, toAlertHistory, 15_000); + return useApiQuery( + "/v1/alert-history", {}, EMPTY_LIST as unknown as AlertHistoryEntry[], toAlertHistory, 15_000, + ); } // ─── Alert channels (real delivery targets) ────────────────────────────────── @@ -353,11 +342,10 @@ export interface CreateChannelPayload { export function useChannels() { const { activeProject } = useProjects(); const project = activeProject?.id; - const fallback = useMemo(() => [], []); const query = useApiQuery( "/v1/channels", {}, - fallback, + EMPTY_LIST as unknown as AlertChannel[], (raw) => (raw as ApiChannel[]).map(toChannel), 15_000, ); diff --git a/hooks/use-live-metrics.ts b/hooks/use-live-metrics.ts index e122094..aa04222 100644 --- a/hooks/use-live-metrics.ts +++ b/hooks/use-live-metrics.ts @@ -8,55 +8,25 @@ import type { LogEntry } from "@/lib/types"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; const LOG_BUFFER = 200; -// ── Demo mode ──────────────────────────────────────────────────────────────── -// When no live backend is connected, synthesize a realistic request stream so -// the dashboard reads as live instead of sitting empty. -const DEMO_ROUTES: { method: LogEntry["method"]; route: string; weight: number }[] = [ - { method: "GET", route: "/api/products", weight: 9 }, - { method: "GET", route: "/api/users/:id", weight: 7 }, - { method: "GET", route: "/health", weight: 6 }, - { method: "POST", route: "/api/checkout", weight: 4 }, - { method: "GET", route: "/api/search", weight: 5 }, - { method: "POST", route: "/api/orders", weight: 4 }, - { method: "POST", route: "/api/auth/login", weight: 3 }, - { method: "POST", route: "/api/payments", weight: 3 }, - { method: "PATCH", route: "/api/users/:id", weight: 2 }, - { method: "DELETE", route: "/api/products/:id", weight: 1 }, - { method: "GET", route: "/api/analytics/events", weight: 3 }, -]; -const DEMO_POOL = DEMO_ROUTES.flatMap((r) => Array(r.weight).fill(r)); - -function mockLog(): LogEntry { - const pick = DEMO_POOL[Math.floor(Math.random() * DEMO_POOL.length)]; - const roll = Math.random(); - // Mostly 2xx, occasional 4xx, rare 5xx — skew errors toward checkout/auth. - const risky = pick.route.includes("checkout") || pick.route.includes("auth"); - let status = 200; - if (roll > (risky ? 0.82 : 0.94)) status = 500; - else if (roll > (risky ? 0.7 : 0.9)) status = 401; - else if (pick.method === "POST") status = 201; - const base = pick.route.includes("checkout") || pick.route.includes("payments") ? 260 : 40; - return { - id: crypto.randomUUID(), - timestamp: new Date(), - method: pick.method, - route: pick.route, - status, - latency: Math.round(base + Math.random() * base * (status >= 500 ? 4 : 1.5)), - userId: `u_${Math.floor(1000 + Math.random() * 8999)}`, - }; -} - +export type LiveLogStatus = "no-project" | "connecting" | "live" | "reconnecting"; + +/** + * Streams real request-log entries over SSE for a project. Never fabricates + * rows: with no project, no realtime token, or a dropped connection, `logs` + * simply stays whatever real entries have arrived (possibly empty) and + * `status` tells the caller what's actually going on, so the UI can show an + * honest "connecting…" / "reconnecting…" / "no requests yet" state instead + * of a fake-but-convincing feed. + */ export function useLiveLog(maxEntries = LOG_BUFFER) { const [logs, setLogs] = useState([]); const [paused, setPaused] = useState(false); - const [connected, setConnected] = useState(false); + const [status, setStatus] = useState("connecting"); const pausedRef = useRef(paused); const retryDelay = useRef(1_000); const esRef = useRef(null); const timerRef = useRef | null>(null); - const mockRef = useRef | null>(null); const { activeProject } = useProjects(); const projectId = activeProject?.id ?? null; @@ -67,30 +37,23 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { useEffect(() => { let cancelled = false; - let token: string | null = null; - - function startMock() { - if (mockRef.current) return; - // Seed a full-looking buffer, then stream new entries in. - setLogs(Array.from({ length: 18 }, mockLog)); - mockRef.current = setInterval(() => { - if (pausedRef.current) return; - setLogs((prev) => [mockLog(), ...prev].slice(0, maxEntries)); - }, 900); - } + let hasConnectedOnce = false; async function connect() { if (cancelled) return; if (!projectId) { - startMock(); + setStatus("no-project"); return; } // Short-lived, project-scoped realtime token (Phase 8.4) — refetched on // each (re)connect so an expired token self-heals. - token = await fetchRealtimeToken(projectId); + const token = await fetchRealtimeToken(projectId); if (cancelled) return; if (!token) { - startMock(); + setStatus(hasConnectedOnce ? "reconnecting" : "connecting"); + const delay = retryDelay.current; + retryDelay.current = Math.min(delay * 2, 8_000); + timerRef.current = setTimeout(connect, delay); return; } @@ -119,28 +82,24 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { }); es.onopen = () => { - setConnected(true); + hasConnectedOnce = true; + setStatus("live"); retryDelay.current = 1_000; - // Real stream is live — drop the demo fallback. - if (mockRef.current) { - clearInterval(mockRef.current); - mockRef.current = null; - } }; es.onerror = () => { - setConnected(false); + setStatus(hasConnectedOnce ? "reconnecting" : "connecting"); es.close(); esRef.current = null; - // Backend unreachable — fall back to the demo stream so the UI stays live. - startMock(); - // Exponential back-off: 1 s → 2 s → 4 s → 8 s + // Exponential back-off: 1 s → 2 s → 4 s → 8 s. No fake data in the + // meantime — the log just stops growing until the stream recovers. const delay = retryDelay.current; retryDelay.current = Math.min(delay * 2, 8_000); timerRef.current = setTimeout(connect, delay); }; } + setLogs([]); void connect(); return () => { @@ -148,15 +107,11 @@ export function useLiveLog(maxEntries = LOG_BUFFER) { esRef.current?.close(); esRef.current = null; if (timerRef.current) clearTimeout(timerRef.current); - if (mockRef.current) { - clearInterval(mockRef.current); - mockRef.current = null; - } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [maxEntries, projectId]); - return { logs, paused, setPaused, connected }; + return { logs, paused, setPaused, status, connected: status === "live" }; } export function useLiveCounter(base: number, variance = 0.05) { diff --git a/hooks/use-time-range.ts b/hooks/use-time-range.ts index ddee9ff..0c0bab3 100644 --- a/hooks/use-time-range.ts +++ b/hooks/use-time-range.ts @@ -7,6 +7,7 @@ export function useTimeRange(defaultRange: TimeRange = "24h") { const [range, setRange] = useState(defaultRange); const hours: Record = { + live: 0, // unused — "live" reads from the realtime socket stream, not this REST param "1h": 1, "6h": 6, "24h": 24, diff --git a/hooks/use-websocket.ts b/hooks/use-websocket.ts index 35f9e67..b9adb0a 100644 --- a/hooks/use-websocket.ts +++ b/hooks/use-websocket.ts @@ -7,31 +7,21 @@ import type { Incident, StatCard } from "@/lib/types"; const SPARKLINE_LENGTH = 20; -function emptyCards(): StatCard[] { +function emptyCards(label = "connecting…"): StatCard[] { return [ - { label: "Requests / min", value: "—", unit: "req", delta: 0, deltaLabel: "connecting…", sparkline: [] }, - { label: "Error Rate", value: "—", unit: "%", delta: 0, deltaLabel: "connecting…", sparkline: [] }, - { label: "p99 Latency", value: "—", unit: "ms", delta: 0, deltaLabel: "connecting…", sparkline: [] }, - { label: "Avg Latency", value: "—", unit: "ms", delta: 0, deltaLabel: "connecting…", sparkline: [] }, + { label: "Requests / min", value: "—", unit: "req", delta: 0, deltaLabel: label, sparkline: [] }, + { label: "Error Rate", value: "—", unit: "%", delta: 0, deltaLabel: label, sparkline: [] }, + { label: "p99 Latency", value: "—", unit: "ms", delta: 0, deltaLabel: label, sparkline: [] }, + { label: "Avg Latency", value: "—", unit: "ms", delta: 0, deltaLabel: label, sparkline: [] }, ]; } -/** Realistic metric tick for demo mode when no live backend is connected. */ -function mockMetric(): MetricUpdate { - return { - requests: Math.round(2200 + Math.random() * 520), - errorRate: Math.round((0.3 + Math.random() * 1.7) * 10) / 10, - p99: Math.round(250 + Math.random() * 95), - avg: Math.round(90 + Math.random() * 55), - bucket: new Date().toISOString(), - }; -} - /** * Streams live stat-card data for a project. Fetches a short-lived, project- - * scoped realtime token from the BFF (no API key in the browser). When there's - * no project / backend, falls back to a self-updating demo stream so the - * dashboard always feels alive instead of stuck "connecting…". + * scoped realtime token from the BFF (no API key in the browser). Never + * fabricates numbers: with no project, no realtime token, or simply no + * traffic in the last minute, the cards stay at the honest "—" empty state — + * that's the truth, not a reason to make something up. */ export function useMetrics(projectId: string | null) { const [cards, setCards] = useState(emptyCards); @@ -101,39 +91,31 @@ export function useMetrics(projectId: string | null) { ]); } - let mockTimer: ReturnType | null = null; - - function startMock() { - if (mockTimer) return; - // Seed history so sparklines render full immediately. - for (let i = 0; i < SPARKLINE_LENGTH; i++) applyUpdate(mockMetric()); - mockTimer = setInterval(() => applyUpdate(mockMetric()), 2000); - } - - // No project context (logged-out / loading) → synthetic demo so the UI - // isn't dead. A REAL project never shows synthetic numbers. + // No project context yet (logged-out / still loading) — nothing to + // connect to. Stay on the honest empty state, no synthetic numbers. if (!projectId) { - startMock(); - return () => { - if (mockTimer) clearInterval(mockTimer); - }; + history.current = { requests: [], errorRate: [], p99: [], avg: [] }; + setCards(emptyCards("no project selected")); + return; } + // Clear any previous project's values before this connection attempt — + // otherwise, if this project simply has no traffic in the last minute, + // the last project's numbers would stay on screen looking exactly like + // this project's live data instead of the honest "—" empty state. + history.current = { requests: [], errorRate: [], p99: [], avg: [] }; + setCards(emptyCards()); + let cancelled = false; let socket: ReturnType | null = null; let onMetric: ((update: MetricUpdate) => void) | null = null; void (async () => { const token = await fetchRealtimeToken(projectId); - if (cancelled) return; - if (!token) { - // Realtime unavailable entirely (backend down) — demo fallback for dev. - startMock(); - return; - } - // Real project + realtime available → show ONLY real metrics. When there's - // no traffic in the last minute the worker publishes nothing, so the cards - // stay at their empty ("—") state — which is the honest answer. + if (cancelled || !token) return; + // When there's no traffic in the last minute the worker publishes + // nothing, so the cards stay at their empty ("—") state — the honest + // answer, not a reason to fabricate a number. socket = getSocket(token); onMetric = (update: MetricUpdate) => applyUpdate(update); socket.on("metric", onMetric); @@ -142,13 +124,72 @@ export function useMetrics(projectId: string | null) { return () => { cancelled = true; if (socket && onMetric) socket.off("metric", onMetric); - if (mockTimer) clearInterval(mockTimer); }; }, [projectId]); return cards; } +const LIVE_CHART_BUFFER = 120; // ~2 minutes at the worker's 1-tick/sec cadence + +export interface LiveChartPoint { + time: string; + requests: number; + errors2xx: number; + errors4xx: number; + errors5xx: number; + p99: number; +} + +/** + * Rolling ~2-minute buffer of real request-rate data for the "Live" chart + * option — fed from the exact same per-second Socket.io metric stream that + * powers the stat cards (already proven accurate: a real 60s-window COUNT() + * from Postgres, not an estimate). Never interpolates or backfills; it only + * ever contains ticks that actually arrived, so a quiet stretch is a quiet + * stretch, not a smoothed guess. + */ +export function useLiveChartData(projectId: string | null): LiveChartPoint[] { + const [points, setPoints] = useState([]); + + useEffect(() => { + setPoints([]); + if (!projectId) return; + + let cancelled = false; + let socket: ReturnType | null = null; + let onMetric: ((update: MetricUpdate) => void) | null = null; + + void (async () => { + const token = await fetchRealtimeToken(projectId); + if (cancelled || !token) return; + socket = getSocket(token); + onMetric = (update: MetricUpdate) => { + const point: LiveChartPoint = { + // Raw ISO instant, not a pre-formatted string — formatted for + // display client-side by formatChartTime, same as the REST + // timeseries data these points render alongside. + time: new Date().toISOString(), + requests: update.requests, + errors2xx: update.req2xx ?? 0, + errors4xx: update.req4xx ?? 0, + errors5xx: update.req5xx ?? 0, + p99: update.p99, + }; + setPoints((prev) => [...prev, point].slice(-LIVE_CHART_BUFFER)); + }; + socket.on("metric", onMetric); + })(); + + return () => { + cancelled = true; + if (socket && onMetric) socket.off("metric", onMetric); + }; + }, [projectId]); + + return points; +} + interface RawIncidentPush { id: string; severity: string; diff --git a/lib/mock-data.ts b/lib/mock-data.ts deleted file mode 100644 index 9756190..0000000 --- a/lib/mock-data.ts +++ /dev/null @@ -1,358 +0,0 @@ -import type { - LogEntry, - Incident, - Endpoint, - Trace, - TraceSpan, - AlertRule, - ServiceStatus, - ChartData, - StatCard, -} from "./types"; - -function randomBetween(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function generateSparkline(base: number, points = 12): number[] { - return Array.from({ length: points }, () => - Math.max(0, base + randomBetween(-base * 0.3, base * 0.3)) - ); -} - -export function getStatCards(): StatCard[] { - return [ - { - label: "Requests / min", - value: "2.4k", - delta: 12, - deltaLabel: "vs yesterday", - sparkline: generateSparkline(2400), - }, - { - label: "Error Rate", - value: "1.8", - unit: "%", - delta: -0.3, - deltaLabel: "vs yesterday", - sparkline: generateSparkline(1.8), - }, - { - label: "p99 Latency", - value: "284", - unit: "ms", - delta: 22, - deltaLabel: "vs yesterday", - sparkline: generateSparkline(284), - }, - { - label: "Active Users", - value: "1.2k", - delta: 5, - deltaLabel: "vs yesterday", - sparkline: generateSparkline(1200), - }, - ]; -} - -export function generateChartData(hours: number): ChartData[] { - const points = hours <= 6 ? hours * 12 : hours <= 24 ? hours * 4 : hours; - const now = Date.now(); - const interval = (hours * 3600000) / points; - - return Array.from({ length: points }, (_, i) => { - const t = new Date(now - (points - i) * interval); - const hour = t.getHours(); - const base = 1800 + Math.sin((hour / 24) * Math.PI * 2) * 600; - const requests = Math.max(0, base + randomBetween(-200, 200)); - const errorRate = Math.random() * 0.04; - - const timeLabel = hours > 24 - ? t.toLocaleDateString([], { month: "short", day: "numeric" }) - : t.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - - return { - time: timeLabel, - requests: Math.floor(requests), - errors2xx: Math.floor(requests * (1 - errorRate) * 0.95), - errors4xx: Math.floor(requests * errorRate * 0.7), - errors5xx: Math.floor(requests * errorRate * 0.3), - p99: randomBetween(180, 420), - }; - }); -} - -const ROUTES = [ - "/api/auth/login", - "/api/auth/refresh", - "/api/users", - "/api/users/:id", - "/api/products", - "/api/products/:id", - "/api/orders", - "/api/orders/:id", - "/api/checkout", - "/api/payments", - "/api/webhooks/stripe", - "/api/search", - "/api/analytics/events", - "/health", -]; - -const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; -const STATUSES = [200, 201, 204, 400, 401, 403, 404, 422, 500, 502, 503]; -const USER_IDS = [ - "user_892x", - "user_110a", - "user_4480", - "anon_io", - "user_7721", - "sys_router", - "user_3301", - "user_9980", -]; - -export function generateLogEntries(count = 50): LogEntry[] { - return Array.from({ length: count }, (_, i) => ({ - id: `log_${Date.now()}_${i}`, - timestamp: new Date(Date.now() - i * 1200 - randomBetween(0, 800)), - method: METHODS[randomBetween(0, METHODS.length - 1)], - route: ROUTES[randomBetween(0, ROUTES.length - 1)], - status: STATUSES[randomBetween(0, STATUSES.length - 1)], - latency: randomBetween(12, 850), - userId: USER_IDS[randomBetween(0, USER_IDS.length - 1)], - })); -} - -export function getIncidents(): Incident[] { - return [ - { - id: "inc_001", - severity: "critical", - title: "p99 Latency Spike on /api/checkout", - summary: - "p99 latency exceeded 2000ms for 8 minutes. Root cause: downstream payment service timeout. Affected ~340 requests. Auto-recovered after payment service restart.", - endpoint: "/api/checkout", - timestamp: new Date(Date.now() - 1800000), - resolved: true, - }, - { - id: "inc_002", - severity: "warning", - title: "Elevated 4xx Rate on /api/auth", - summary: - "4xx error rate rose to 12% on the auth endpoints. Pattern matches credential-stuffing from 3 IP ranges. Rate limiting kicked in at 10:42 AM.", - endpoint: "/api/auth/login", - timestamp: new Date(Date.now() - 5400000), - resolved: false, - }, - { - id: "inc_003", - severity: "info", - title: "Traffic Anomaly — /api/search", - summary: - "Unusual spike in search requests (3.2x normal). Likely a new feature release driving user activity. No error rate increase observed.", - endpoint: "/api/search", - timestamp: new Date(Date.now() - 10800000), - resolved: true, - }, - ]; -} - -export function getEndpoints(): Endpoint[] { - return [ - { id: "ep_01", method: "POST", route: "/api/auth/login", requests24h: 48200, errorRate: 11.2, p50: 89, p95: 210, p99: 380, lastCalled: new Date(Date.now() - 2000), healthScore: 42 }, - { id: "ep_02", method: "GET", route: "/api/products", requests24h: 124000, errorRate: 0.4, p50: 45, p95: 120, p99: 195, lastCalled: new Date(Date.now() - 800), healthScore: 96 }, - { id: "ep_03", method: "GET", route: "/api/users/:id", requests24h: 88000, errorRate: 0.9, p50: 38, p95: 95, p99: 145, lastCalled: new Date(Date.now() - 1200), healthScore: 91 }, - { id: "ep_04", method: "POST", route: "/api/checkout", requests24h: 12400, errorRate: 3.1, p50: 320, p95: 890, p99: 1840, lastCalled: new Date(Date.now() - 4000), healthScore: 58 }, - { id: "ep_05", method: "POST", route: "/api/payments", requests24h: 11800, errorRate: 0.8, p50: 410, p95: 980, p99: 1200, lastCalled: new Date(Date.now() - 5000), healthScore: 84 }, - { id: "ep_06", method: "GET", route: "/api/orders", requests24h: 34000, errorRate: 0.2, p50: 52, p95: 130, p99: 200, lastCalled: new Date(Date.now() - 900), healthScore: 97 }, - { id: "ep_07", method: "POST", route: "/api/webhooks/stripe", requests24h: 8900, errorRate: 0.0, p50: 28, p95: 72, p99: 110, lastCalled: new Date(Date.now() - 3000), healthScore: 100 }, - { id: "ep_08", method: "GET", route: "/api/search", requests24h: 56000, errorRate: 0.6, p50: 85, p95: 240, p99: 420, lastCalled: new Date(Date.now() - 600), healthScore: 89 }, - { id: "ep_09", method: "PUT", route: "/api/users/:id", requests24h: 9200, errorRate: 1.4, p50: 95, p95: 280, p99: 520, lastCalled: new Date(Date.now() - 8000), healthScore: 75 }, - { id: "ep_10", method: "DELETE", route: "/api/products/:id", requests24h: 1200, errorRate: 0.0, p50: 65, p95: 140, p99: 210, lastCalled: new Date(Date.now() - 60000), healthScore: 99 }, - { id: "ep_11", method: "GET", route: "/api/analytics/events", requests24h: 22000, errorRate: 2.8, p50: 180, p95: 540, p99: 980, lastCalled: new Date(Date.now() - 2400), healthScore: 63 }, - { id: "ep_12", method: "GET", route: "/health", requests24h: 288000, errorRate: 0.0, p50: 4, p95: 8, p99: 12, lastCalled: new Date(Date.now() - 100), healthScore: 100 }, - ]; -} - -export function getTraces(): Trace[] { - return Array.from({ length: 20 }, (_, i) => { - const traceId = `trace_${(i + 1).toString().padStart(4, "0")}`; - const hasError = i % 7 === 0; - const totalDuration = randomBetween(80, 1800); - - const spans: TraceSpan[] = [ - { - id: `${traceId}_s1`, - traceId, - service: "api-gateway", - name: "HTTP POST /api/checkout", - startTime: 0, - duration: totalDuration, - status: hasError ? "error" : "ok", - tags: { "http.method": "POST", "http.url": "/api/checkout", "http.status_code": hasError ? "500" : "200" }, - }, - { - id: `${traceId}_s2`, - traceId, - parentId: `${traceId}_s1`, - service: "auth-service", - name: "ValidateToken", - startTime: 5, - duration: randomBetween(10, 40), - status: "ok", - tags: { "user.id": "user_892x" }, - }, - { - id: `${traceId}_s3`, - traceId, - parentId: `${traceId}_s1`, - service: "product-service", - name: "GetCartItems", - startTime: 45, - duration: randomBetween(20, 80), - status: "ok", - tags: { "db.type": "postgres" }, - }, - { - id: `${traceId}_s4`, - traceId, - parentId: `${traceId}_s1`, - service: "payment-service", - name: "ChargeCard", - startTime: 130, - duration: randomBetween(100, 800), - status: hasError ? "error" : "ok", - tags: { "payment.provider": "stripe" }, - }, - ]; - - return { - id: traceId, - rootSpan: `${traceId}_s1`, - service: "api-gateway", - endpoint: "/api/checkout", - totalDuration, - timestamp: new Date(Date.now() - i * 180000), - status: hasError ? "error" : "ok", - spans, - }; - }); -} - -export function getAlertRules(): AlertRule[] { - return [ - { - id: "rule_01", - name: "High Error Rate", - metric: "error_rate", - operator: ">", - threshold: 5, - window: 5, - severity: "critical", - channel: "Slack #alerts", - status: "ok", - enabled: true, - }, - { - id: "rule_02", - name: "p99 Latency Spike", - metric: "p99_latency", - operator: ">", - threshold: 2000, - window: 3, - severity: "critical", - channel: "PagerDuty", - status: "firing", - lastTriggered: new Date(Date.now() - 1800000), - enabled: true, - }, - { - id: "rule_03", - name: "Low Traffic Warning", - metric: "requests_per_min", - operator: "<", - threshold: 100, - window: 10, - severity: "warning", - channel: "Slack #warnings", - status: "ok", - enabled: true, - }, - { - id: "rule_04", - name: "Auth 4xx Spike", - metric: "auth_4xx_rate", - operator: ">", - threshold: 10, - window: 2, - severity: "warning", - channel: "Discord", - status: "firing", - lastTriggered: new Date(Date.now() - 5400000), - enabled: true, - }, - { - id: "rule_05", - name: "Payment Errors", - metric: "payment_error_rate", - operator: ">", - threshold: 2, - window: 5, - severity: "critical", - channel: "PagerDuty", - status: "ok", - enabled: false, - }, - ]; -} - -export function getServiceStatuses(): ServiceStatus[] { - return [ - { - id: "svc_01", - name: "API Gateway", - uptime90d: 99.98, - currentStatus: "operational", - responseTime: 45, - uptimeBars: Array.from({ length: 90 }, (_, i) => (i === 12 || i === 45 ? "degraded" : "up")), - }, - { - id: "svc_02", - name: "Authentication", - uptime90d: 99.91, - currentStatus: "degraded", - responseTime: 120, - uptimeBars: Array.from({ length: 90 }, (_, i) => - i === 88 || i === 89 ? "degraded" : i === 30 ? "down" : "up" - ), - }, - { - id: "svc_03", - name: "Product Catalog", - uptime90d: 100, - currentStatus: "operational", - responseTime: 38, - uptimeBars: Array.from({ length: 90 }, () => "up" as const), - }, - { - id: "svc_04", - name: "Payments", - uptime90d: 99.99, - currentStatus: "operational", - responseTime: 410, - uptimeBars: Array.from({ length: 90 }, (_, i) => (i === 60 ? "down" : "up")), - }, - { - id: "svc_05", - name: "Search", - uptime90d: 99.85, - currentStatus: "operational", - responseTime: 85, - uptimeBars: Array.from({ length: 90 }, (_, i) => - [20, 21, 55].includes(i) ? "degraded" : "up" - ), - }, - ]; -} diff --git a/lib/project-access.ts b/lib/project-access.ts new file mode 100644 index 0000000..b903ae8 --- /dev/null +++ b/lib/project-access.ts @@ -0,0 +1,27 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +function internalToken(): string { + return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? ""; +} + +/** + * Fresh, per-request check that `email` may read `projectId` — queries org + * membership straight from the backend rather than trusting a cached list. + * + * A project's membership can change after a session is issued (a new project + * created, a team invite accepted), and NextAuth JWT sessions are not + * re-derived on every request — only at sign-in. Caching this list in the + * session (as `session.projectIds` used to) meant any project created after + * login was invisible to every dashboard read and realtime connection until + * the user signed out and back in. This mirrors the same "ask the backend, + * not a stale cache" pattern already used by rotate/delete/status-page BFF + * routes (`_require_member` on the backend side). + */ +export async function verifyProjectAccess(email: string, projectId: string): Promise { + const res = await fetch( + `${API_URL}/v1/internal/access?email=${encodeURIComponent(email)}`, + { headers: { "x-internal-token": internalToken() }, cache: "no-store" } + ); + if (!res.ok) return false; + const data = (await res.json()) as { projects?: { id: string }[] }; + return data.projects?.some((p) => p.id === projectId) ?? false; +} diff --git a/lib/socket.ts b/lib/socket.ts index 137dad1..efbdb48 100644 --- a/lib/socket.ts +++ b/lib/socket.ts @@ -7,6 +7,9 @@ export interface MetricUpdate { errorRate: number; // 0–100 p99: number; // milliseconds avg: number; // milliseconds + req2xx?: number; + req4xx?: number; + req5xx?: number; bucket: string | null; } diff --git a/lib/types.ts b/lib/types.ts index 3c50f51..31c287a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,4 @@ -export type TimeRange = "1h" | "6h" | "24h" | "7d"; +export type TimeRange = "live" | "1h" | "6h" | "24h" | "7d"; export interface StatCard { label: string; diff --git a/lib/utils.ts b/lib/utils.ts index c3e1a44..5cb6497 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -16,6 +16,22 @@ export function formatMs(ms: number): string { return `${ms}ms`; } +/** + * Formats a chart timestamp (ISO 8601 UTC instant) in the viewer's own local + * timezone — always includes the date, not just the hour, since any bucket + * window can span a day boundary (whenever "now" is within it of midnight), + * and an hour-only label can't tell you "09:30" is 13 hours after "20:15" + * rather than 13 hours before it. + */ +export function formatChartTime(iso: string): string { + const d = new Date(iso); + const date = d.toLocaleDateString([], { month: "2-digit", day: "2-digit" }); + // Explicit 24h — the locale default can be 12h with an AM/PM suffix, which + // is long enough to make already-tight tick labels run into each other. + const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); + return `${date} ${time}`; +} + export function formatPercent(n: number, decimals = 1): string { return `${n.toFixed(decimals)}%`; } diff --git a/proxy.ts b/proxy.ts index f8439db..c30de4a 100644 --- a/proxy.ts +++ b/proxy.ts @@ -15,7 +15,10 @@ export const config = { "/endpoints/:path*", "/traces/:path*", "/alerts/:path*", - "/pipelines/:path*", "/settings/:path*", + // Exact match only — the authenticated status *preview*. Must NOT be a + // wildcard: /status/[slug], /status/confirm/[token], and + // /status/unsubscribe/[token] are intentionally public (no session). + "/status", ], }; diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index cdea171..807a29b 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -5,8 +5,6 @@ declare module "next-auth" { user: { id: string; } & DefaultSession["user"]; - /** Project IDs the signed-in user is authorized to read (org membership scope). */ - projectIds?: string[]; /** The project shown by default in the dashboard. */ defaultProjectId?: string; } @@ -15,7 +13,6 @@ declare module "next-auth" { declare module "next-auth/jwt" { interface JWT { userId?: string; - projectIds?: string[]; defaultProjectId?: string; } }
{stat.label}
{stat.value}
Rule Builder
+
{preview}
No channels yet. Add one to receive alert notifications.
No alert history yet.
{h.ruleName}
{timeAgo(h.firedAt)} · {h.duration} · via {h.channel}
Pipelines
{session?.user?.name ?? "—"}
{session?.user?.email ?? "—"}
Loading…
No projects yet.
{trace.endpoint}
Sign in to your Liveboard workspace
By signing in you agree to our{" "} - Terms of Service + Terms of Service {" "}and{" "} - Privacy Policy. + Privacy Policy.
Open source API observability
No components to report yet.
{data.detail}
{incident.summary}
Timeline
{note}
+ This page isn't public yet — enable it from the project switcher to get a shareable link. +
- Last checked: {new Date().toLocaleTimeString()} · Updates in real time -
No traffic recorded yet — components appear here once your API receives requests.
- Subscribe to receive email notifications for incidents and status updates. -
{name ?? "User"}
{email}
{name}
{message}
{label}
{label ? formatChartTime(label) : ""}
{formatNumber(payload[0].value)} req/min
Active Pipeline Architecture
- 1. INGRESS (ENTRY POINT) -
- 2. PRE-PROCESSING (MIDDLEWARE) -
- 3. DYNAMIC ROUTER (DECISION MATRIX) -
{f.label}
{f.value}
{incident.title}
{timeAgo(incident.timestamp)}
Resolved
Select a project to see its live requests.
Connecting to the live stream…
Connection dropped — reconnecting…
+ No requests yet — they'll show up here as soon as your API gets traffic. +
Endpoint Traffic & Health
API Gateway
/v1/chat/completions
124k Reqs
{card.label}
{m.label}
p99 Latency — 24h
Latency Distribution
{p.label}
{p.value}
Top Errors
No errors in selected window
{result.project.name}
@@ -153,7 +153,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) {
Quick start
npm install liveboard
LIVEBOARD_API_KEY={result.project.apiKeyMasked}
Projects
{project.name}
Created {timeAgo(new Date(project.createdAt))}
API key
New key generated — copy it now, it won't be shown again.
{newKey}
+ {project.apiKeyMasked} +
+ This immediately invalidates the current key — anything using it stops working until updated. +
Public status page
+ Anyone with the link can view it — no account needed. +
{statusError}
Danger zone
+ This permanently deletes {project.name} and + all its events, traces, alert rules, and API keys. Type the project name to confirm. +
{deleteError}
+ {lastChecked ? `Last checked: ${lastChecked} · ` : ""}Updates in real time +
+ Subscribe to receive email notifications for incidents and status updates. +
Check your email to confirm the subscription.
Something went wrong — try again.
{service.uptime90d.toFixed(2)}%
uptime (90d)
Service Map
{field.label}
0 && (
Tags