diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 01ecbbe..76bd885 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -13,11 +13,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_API_URL -ARG NEXT_PUBLIC_WS_URL -ARG NEXT_PUBLIC_LIVEBOARD_API_KEY ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL -ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL -ENV NEXT_PUBLIC_LIVEBOARD_API_KEY=$NEXT_PUBLIC_LIVEBOARD_API_KEY RUN npm run build # Production runtime diff --git a/README.md b/README.md index 83ed9c6..81d7c39 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **Know the moment your API breaks.** -Real-time traces, live error tracking, and AI-written incident summaries — wired up with one line of middleware. +Live request metrics, error tracking, and AI-written incident summaries — wired up with one line of middleware. Open source. Self-hostable. No agents, no sidecars. @@ -36,13 +36,13 @@ Open source. Self-hostable. No agents, no sidecars. ## What is Liveboard? -Liveboard is a self-hostable API observability platform — the open-source pieces you'd otherwise assemble from Datadog, Sentry, and a status page tool. Drop one line of middleware into an Express, Fastify, FastAPI, Django, or Flask app and get live request metrics, distributed traces, error tracking, AI-generated incident summaries, alert rules, and a public status page — all streaming in real time over WebSockets/SSE. +Liveboard is a self-hostable API observability platform — the open-source pieces you'd otherwise assemble from Datadog and Sentry. Drop one line of middleware into an Express or FastAPI app and get live request metrics, per-endpoint latency percentiles, error tracking, and AI-generated incident summaries — all streaming in real time over WebSockets/SSE. - **60-second onboarding** — `npm install liveboard-sdk` or `pip install liveboard-sdk`, one line of middleware, data on the dashboard in under 90 seconds. - **User-centric observability** — every event is tagged with `user_id`, so you can see exactly *which* users are hitting errors, not just aggregate rates. - **AI incident summaries** — a rolling z-score detector flags anomalies in error rate and p99 latency, then an LLM (Cerebras `llama-3.3-70b`) writes a plain-English summary of what happened. -- **OpenTelemetry-flavored tracing** — trace IDs propagate across all five SDK adapters into a flame graph and service map, no collector required. -- **Auto-generated public status page** — 90-day uptime bars, incident timelines, and email subscriptions, derived from the same live data. +- **Built for throughput** — events land in a Redis Stream and a consumer-group worker bulk-writes them into TimescaleDB with the `COPY` protocol, sized for 120K events/min on one box. +- **Multi-tenant from day one** — organizations, per-project API keys, and Postgres Row-Level Security as a database-level isolation backstop. @@ -50,16 +50,13 @@ Liveboard is a self-hostable API observability platform — the open-source piec | | | |---|---| -| 📊 **Live dashboard** | Request volume, stacked 2xx/4xx/5xx error rate, animated stat cards with sparklines — all pushed over WebSockets as traffic happens. | -| 🔎 **Endpoint explorer** | Sortable table with p50/p95/p99, health scores, latency histograms, top errors and top affected users per route. Side-by-side endpoint comparison mode. | -| 🔥 **Distributed traces** | Flame graphs, span detail panels, critical-path highlighting, and a pure-SVG service dependency map. | -| 🚨 **Alert rules** | Metric + operator + threshold + window rule builder with a live plain-English preview, per-channel delivery, and alert history. | -| 🤖 **AI anomaly detection** | Rolling 24h z-score on error rate & p99 latency; anomalies trigger a rate-limited, deduplicated LLM incident summary. | -| 🟢 **Public status page** | Auto-generated per project — overall status badge, 90-day uptime bars, incident timelines, email subscribe/unsubscribe. | +| 📊 **Live dashboard** | Request volume, stacked 2xx/4xx/5xx response codes, animated stat cards with sparklines, and a tailing request log — all pushed over WebSockets/SSE as traffic happens. | +| 🔎 **Endpoint explorer** | Sortable per-route table with p50/p95/p99 latency, error rate and a derived health score, filterable by route and HTTP method. | +| 🤖 **AI anomaly detection** | Rolling 24h z-score on error rate & p99 latency; anomalies trigger a rate-limited, deduplicated LLM incident summary (Cerebras `llama-3.3-70b`). | | ⚡ **Real-time everything** | Socket.io for live metrics + incidents, SSE with `Last-Event-ID` resume for the live log tail — zero missed events on reconnect. | | 🏢 **Multi-tenant by default** | Organizations, memberships, and per-project API keys with Postgres Row-Level Security as a DB-level tenant-isolation backstop. | | 🔐 **Google OAuth + sessions** | NextAuth/Auth.js sign-in; the browser never sees a raw ingest key — reads go through a session-scoped BFF proxy. | -| 📦 **Two official SDKs** | JavaScript/TypeScript (Express, Fastify) and Python (FastAPI, Django, Flask), both with automatic route normalisation and trace propagation. | +| 📦 **Two official SDKs** | JavaScript/TypeScript (Express) and Python (FastAPI/ASGI), both with automatic route normalisation and background batching that never blocks your app. | @@ -78,28 +75,6 @@ Liveboard is a self-hostable API observability platform — the open-source piec **Endpoint Explorer** Endpoint explorer - - - - - -**Distributed Traces** -Distributed trace flame graph - - - - -**Alert Rules** -Alert rules - - - - - - -**Public Status Page** -Public status page - @@ -154,7 +129,7 @@ import liveboard from "liveboard-sdk"; app.use(liveboard.middleware({ apiKey })); ``` -Works with your existing **Express** or **Fastify** app. +Works with your existing **Express** app. @@ -169,13 +144,13 @@ from liveboard.asgi import LiveBoardMiddleware app.add_middleware(LiveBoardMiddleware, api_key=key) ``` -Works with **FastAPI**, **Django**, or **Flask**. +Works with **FastAPI** and any other ASGI framework (Starlette). -Every adapter normalises dynamic route segments (`/users/507f191e...` → `/users/:id`), propagates an `x-trace-id` header across services, and tags events with the authenticated `user_id` when it can find one — powering the endpoint explorer, trace viewer, and per-user error breakdowns out of the box. +Both adapters normalise dynamic route segments (`/users/507f191e...` → `/users/:id`), echo an `x-trace-id` correlation header, and tag events with the authenticated `user_id` when they can find one — powering the endpoint explorer and per-user error breakdowns out of the box. @@ -184,16 +159,16 @@ Every adapter normalises dynamic route segments (`/users/507f191e...` → `/user ```mermaid flowchart LR subgraph SDKs["Client SDKs"] - JS["liveboard-sdk JS
Express · Fastify"] - PY["liveboard-sdk Python
FastAPI · Django · Flask"] + JS["liveboard-sdk JS
Express"] + PY["liveboard-sdk Python
FastAPI / ASGI"] end JS -->|"x-api-key, batched events"| INGEST PY -->|"x-api-key, batched events"| INGEST - INGEST["FastAPI Ingest
POST /v1/ingest, /v1/spans"] --> STREAM[["Redis Streams
events:project_id"]] + INGEST["FastAPI Ingest
POST /v1/ingest"] --> STREAM[["Redis Streams
events:project_id"]] STREAM --> WORKER["Aggregation Worker
asyncpg COPY, at-least-once"] - WORKER --> DB[("TimescaleDB
events · events_1min · spans")] + WORKER --> DB[("TimescaleDB
events · events_1min · incidents")] METRICS["Metrics Worker
1s tick"] --> DB METRICS -->|pub/sub| RT["Socket.io + SSE"] @@ -270,22 +245,21 @@ cd packages/sdk-python && ruff check liveboard/ ``` liveboard/ -├── app/ # Next.js App Router — dashboard, auth, status page, BFF routes -│ ├── (dashboard)/ # overview · endpoints · traces · alerts · settings -│ ├── api/ # lb/[...path] BFF proxy, auth, projects, realtime-token -│ └── status/[slug]/ # public status page + subscribe/unsubscribe -├── components/ # React components (charts, dashboard, traces, status, landing…) -├── hooks/ # useMetrics, useLiveLog, useTraces, useWebSocket, … +├── app/ # Next.js App Router — dashboard, auth, BFF routes +│ ├── (dashboard)/ # overview · endpoints · settings +│ └── api/ # lb/[...path] BFF proxy, auth, projects, realtime-token +├── components/ # React components (charts, dashboard, endpoints, landing…) +├── hooks/ # useMetrics, useLiveLog, useWebSocket, useApiQuery, … ├── lib/ # api-client, socket, realtime helpers ├── apps/api/ # FastAPI backend -│ ├── api/routes/ # ingest · query · alerts · spans · projects · internal · public_status -│ ├── worker/ # aggregator · metrics · anomaly (AI) · alerts +│ ├── api/routes/ # ingest · query · projects · internal +│ ├── worker/ # aggregator · metrics · anomaly (AI) │ ├── realtime/ # socket_server · sse · pubsub · tokens │ ├── streams/ # Redis Streams producer -│ └── migrations/ # Alembic migrations +│ └── migrations/ # Alembic migrations (001 → 004) ├── packages/ -│ ├── sdk-js/ # liveboard-sdk (npm) — Express, Fastify -│ └── sdk-python/ # liveboard-sdk (PyPI) — FastAPI, Django, Flask +│ ├── sdk-js/ # liveboard-sdk (npm) — Express +│ └── sdk-python/ # liveboard-sdk (PyPI) — FastAPI / ASGI ├── infra/ │ └── docker-compose.yml # postgres · redis · api · worker · frontend └── .env.example # every config variable, documented @@ -299,13 +273,15 @@ Liveboard is shipped through self-hosted, multi-tenant SaaS foundations (organiz - [ ] CLI (`liveboard-cli`) for local onboarding and key management - [ ] Hosted Mintlify docs site — quick start, SDK reference, self-hosting guide, architecture -- [ ] Custom domains for public status pages +- [ ] Alerting: threshold rules with Slack/webhook delivery +- [ ] Public status pages with 90-day uptime history +- [ ] Distributed tracing: flame graphs and a service dependency map - [ ] Per-tenant retention policies and plan-gated quotas - [ ] Billing (Stripe) — free/pro tiers - [ ] Production deploy guide (Railway for API/worker, Vercel for the dashboard) - [ ] npm / PyPI publish of `liveboard-sdk` for outside consumers -Check [open issues](https://github.com/ryzrr/liveboard/issues) or open one — good-first-issue-sized SDK adapters (Ruby, Go) and alert channels (webhook, PagerDuty) are great starting points. +Check [open issues](https://github.com/ryzrr/liveboard/issues) or open one — good-first-issue-sized SDK adapters (Fastify, Django, Flask, Go) are great starting points. diff --git a/app/(dashboard)/alerts/page.tsx b/app/(dashboard)/alerts/page.tsx deleted file mode 100644 index c7f7ea1..0000000 --- a/app/(dashboard)/alerts/page.tsx +++ /dev/null @@ -1,506 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Plus, ArrowRight, Send, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { Topbar } from "@/components/layout/topbar"; -import { RuleList } from "@/components/alerts/rule-list"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useAlertRules, useAlertHistory, useChannels } from "@/hooks/use-data"; -import type { CreateAlertRulePayload, AlertChannel, CreateChannelPayload } from "@/hooks/use-data"; -import { useProjects } from "@/components/providers/project-provider"; -import { timeAgo } from "@/lib/utils"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -type ChannelType = "slack" | "discord" | "pagerduty" | "webhook"; - -const CHANNEL_ICON: Record = { - slack: "💬", - discord: "🎮", - pagerduty: "📟", - webhook: "🔗", -}; - -const CHANNEL_TYPES: { value: ChannelType; label: string }[] = [ - { value: "slack", label: "Slack" }, - { value: "discord", label: "Discord" }, - { value: "pagerduty", label: "PagerDuty" }, - { value: "webhook", label: "Webhook" }, -]; - - -const SEVERITIES = [ - { value: "critical", label: "Critical", color: "#EF4444" }, - { value: "warning", label: "Warning", color: "#F59E0B" }, - { value: "info", label: "Info", color: "#378ADD" }, -] as const; - -type Severity = "critical" | "warning" | "info"; - -// ─── Channel config form ────────────────────────────────────────────────────── - -const CHANNEL_PLACEHOLDER: Record = { - slack: "https://hooks.slack.com/services/T.../B.../...", - discord: "https://discord.com/api/webhooks/...", - pagerduty: "PagerDuty Events v2 routing key", - webhook: "https://your-server.com/webhook", -}; - -function ChannelConfigForm({ channel, onSave, onTest, onDelete, onClose }: { - channel: AlertChannel; - onSave: (id: string, patch: { webhook_url: string; enabled: boolean }) => Promise; - onTest: (id: string) => Promise<{ ok: boolean; detail: string }>; - onDelete: (id: string) => Promise; - onClose: () => void; -}) { - const [url, setUrl] = useState(channel.webhookUrl ?? ""); - const [testing, setTesting] = useState(false); - const [saving, setSaving] = useState(false); - const [testResult, setTestResult] = useState<{ ok: boolean; detail: string } | null>(null); - - async function handleTest() { - setSaving(true); - // Persist the URL first so the backend test uses the latest value. - try { await onSave(channel.id, { webhook_url: url, enabled: true }); } finally { setSaving(false); } - setTesting(true); - setTestResult(null); - try { setTestResult(await onTest(channel.id)); } - catch { setTestResult({ ok: false, detail: "request failed" }); } - finally { setTesting(false); } - } - - async function handleSave() { - setSaving(true); - try { await onSave(channel.id, { webhook_url: url, enabled: true }); onClose(); } - finally { setSaving(false); } - } - - return ( -
-
-
- {CHANNEL_ICON[channel.type]} - {channel.name} -
- -
- -
- - { setUrl(e.target.value); setTestResult(null); }} - placeholder={CHANNEL_PLACEHOLDER[channel.type]} - className="mt-1 w-full bg-[#111] border border-[#2A2A2A] rounded px-3 py-1.5 text-xs text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue transition-colors font-mono" - /> -
- - {testResult && ( -
- {testResult.ok ? : } - {testResult.ok ? `Test message delivered (${testResult.detail})` : `Delivery failed — ${testResult.detail}`} -
- )} - - {channel.lastDeliveryAt && ( -

- Last delivery: {timeAgo(channel.lastDeliveryAt)} ·{" "} - - {channel.lastDeliveryOk ? "OK" : "Failed"} - -

- )} - -
- - - -
-
- ); -} - -function AddChannelForm({ onCreate, onClose }: { - onCreate: (input: CreateChannelPayload) => Promise; - onClose: () => void; -}) { - const [type, setType] = useState("slack"); - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [saving, setSaving] = useState(false); - - async function handleCreate() { - if (!name.trim() || !url.trim()) return; - setSaving(true); - try { await onCreate({ type, name: name.trim(), webhook_url: url.trim(), enabled: true }); onClose(); } - finally { setSaving(false); } - } - - return ( -
-
- New channel - -
-
- - setName(e.target.value)} - placeholder="Name (e.g. Slack #alerts)" - className="bg-[#111] border border-[#2A2A2A] rounded px-3 py-1.5 text-xs text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue flex-1 min-w-[160px]" - /> -
- setUrl(e.target.value)} - placeholder={CHANNEL_PLACEHOLDER[type]} - className="w-full bg-[#111] border border-[#2A2A2A] rounded px-3 py-1.5 text-xs text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue font-mono" - /> - -
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -const METRIC_TO_KEY: Record = { - "Error Rate": "error_rate", - "p99 Latency": "p99_latency", - "Requests/min": "requests_per_min", - "Apdex Score": "apdex_score", -}; - -const OPERATOR_TO_SYM: Record" | "<" | "=" | "!="> = { - exceeds: ">", - "drops below": "<", - equals: "=", -}; - -export default function AlertsPage() { - const { activeProject } = useProjects(); - const { data: fetchedRules, createRule, toggleRule } = useAlertRules(); - const { data: historyEntries } = useAlertHistory(); - const { data: channels, createChannel, updateChannel, deleteChannel, testChannel } = useChannels(); - - // Local rules state — seeded from API, updated optimistically on create - const [localRules, setLocalRules] = useState(fetchedRules); - // Sync when real data arrives (replaces mock fallback) - useEffect(() => { setLocalRules(fetchedRules); }, [fetchedRules]); - - const [activeTab, setActiveTab] = useState<"rules" | "channels" | "history">("rules"); - const [configuringId, setConfiguringId] = useState(null); - const [addingChannel, setAddingChannel] = useState(false); - const [creating, setCreating] = useState(false); - - // Rule builder state - const [metric, setMetric] = useState("Error Rate"); - const [operator, setOperator] = useState("exceeds"); - const [threshold, setThreshold] = useState("5"); - const [window, setWindow] = useState("5"); - const [severity, setSeverity] = useState("warning"); - // Rule "send to" holds the channel NAME (matched by the evaluation worker). - const [channelName, setChannelName] = useState(""); - - // Default the rule-builder channel to the first enabled channel once loaded. - useEffect(() => { - if (!channelName && channels.length > 0) { - setChannelName(channels.find((c) => c.enabled)?.name ?? channels[0].name); - } - }, [channels, channelName]); - - const firing = localRules.filter((r) => r.status === "firing").length; - const triggeredToday = historyEntries.filter((h) => { - const d = h.firedAt; - const now = new Date(); - return d.getFullYear() === now.getFullYear() && - d.getMonth() === now.getMonth() && - d.getDate() === now.getDate(); - }).length; - - const preview = `Alert when ${metric} ${operator} ${threshold}${metric === "Error Rate" ? "%" : "ms"} for ${window} min → ${channelName || "no channel"} [${severity}]`; - - async function handleCreateRule() { - const payload: CreateAlertRulePayload = { - name: `${metric} ${operator} ${threshold}`, - metric: METRIC_TO_KEY[metric] ?? metric.toLowerCase().replace(/\s+/g, "_"), - operator: OPERATOR_TO_SYM[operator] ?? ">", - threshold: parseFloat(threshold) || 0, - window: parseInt(window, 10) || 5, - severity, - channel: channelName, - }; - setCreating(true); - try { - const newRule = await createRule(payload); - setLocalRules((prev) => [...prev, newRule]); - } finally { - setCreating(false); - } - } - - async function handleToggle(id: string, enabled: boolean) { - try { - await toggleRule(id, enabled); - } catch { - // Optimistic update already applied in RuleList; silently ignore API errors - } - } - - return ( -
- - - New Rule - - } - /> - -
- {/* Stats */} -
- {[ - { label: "Active Rules", value: localRules.filter((r) => r.enabled).length, color: "#F5F5F5" }, - { label: "Firing Now", value: firing, color: firing > 0 ? "#EF4444" : "#22C55E" }, - { label: "Triggered Today", value: triggeredToday, color: "#F59E0B" }, - ].map((stat) => ( -
-

{stat.label}

-

- {stat.value} -

-
- ))} -
- - {/* Tabs */} -
- {(["rules", "channels", "history"] as const).map((tab) => ( - - ))} -
- - {/* ── Rules tab ── */} - {activeTab === "rules" && ( - <> - - - {/* Enhanced rule builder */} -
-

Rule Builder

- - {/* Main condition row */} -
- Alert when - - - 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 - 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 -
- - {/* Severity + channel row */} -
- Severity -
- {SEVERITIES.map((s) => ( - - ))} -
- - Send to - - - -
- - {/* Preview */} -

- {preview} -

-
- - )} - - {/* ── Channels tab ── */} - {activeTab === "channels" && ( -
-
- {channels.map((ch) => { - const connected = Boolean(ch.webhookUrl) && ch.enabled; - return ( -
-
-
- {CHANNEL_ICON[ch.type]} -
-

{ch.name}

-
- - {connected ? "connected" : "not configured"} - - {ch.lastDeliveryAt && ( - - {timeAgo(ch.lastDeliveryAt)} · {ch.lastDeliveryOk ? "OK" : "failed"} - - )} -
-
-
- -
- {configuringId === ch.id && ( - { await deleteChannel(id); setConfiguringId(null); }} - onClose={() => setConfiguringId(null)} - /> - )} -
- ); - })} - {channels.length === 0 && !addingChannel && ( -

- No channels yet. Add one to receive alert notifications. -

- )} -
- {addingChannel ? ( - { await createChannel(i); }} onClose={() => setAddingChannel(false)} /> - ) : ( - - )} -
- )} - - {/* ── History tab ── */} - {activeTab === "history" && ( -
- {historyEntries.length === 0 && ( -

No alert history yet.

- )} - {historyEntries.map((h) => ( -
-
-
-

{h.ruleName}

-

- {timeAgo(h.firedAt)} · {h.duration} · via {h.channel} -

-
- - {h.resolved ? "Resolved" : "Active"} - -
- ))} -
- )} -
-
- ); -} diff --git a/app/(dashboard)/endpoints/page.tsx b/app/(dashboard)/endpoints/page.tsx index 8ebef61..df65858 100644 --- a/app/(dashboard)/endpoints/page.tsx +++ b/app/(dashboard)/endpoints/page.tsx @@ -1,100 +1,43 @@ "use client"; import { useState, useMemo } from "react"; -import { Search, GitCompare, X } from "lucide-react"; +import { Search } from "lucide-react"; import { Topbar } from "@/components/layout/topbar"; import { TimeRangePicker } from "@/components/layout/time-range-picker"; import { EndpointTable } from "@/components/endpoints/endpoint-table"; -import { EndpointDrawer } from "@/components/endpoints/endpoint-drawer"; -import { EndpointComparison } from "@/components/endpoints/endpoint-comparison"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { useTimeRange } from "@/hooks/use-time-range"; import { useEndpoints } from "@/hooks/use-data"; import { useProjects } from "@/components/providers/project-provider"; -import type { Endpoint } from "@/lib/types"; const METHODS = ["ALL", "GET", "POST", "PUT", "DELETE", "PATCH"] as const; -const STATUS_RANGES = [ - { label: "2xx", min: 200, max: 299 }, - { label: "4xx", min: 400, max: 499 }, - { label: "5xx", min: 500, max: 599 }, -] as const; - export default function EndpointsPage() { const { activeProject } = useProjects(); const { range, setRange } = useTimeRange("24h"); - const [selected, setSelected] = useState(null); const [search, setSearch] = useState(""); const [methodFilter, setMethodFilter] = useState("ALL"); - const [statusFilter, setStatusFilter] = useState>(new Set()); - const [maxLatency, setMaxLatency] = useState(""); - - // Comparison mode - const [compareMode, setCompareMode] = useState(false); - const [compareSelection, setCompareSelection] = useState([]); - const { data: endpoints } = useEndpoints(range === "7d" ? 168 : range === "24h" ? 24 : range === "6h" ? 6 : 1); + const { data: endpoints } = useEndpoints( + range === "7d" ? 168 : range === "24h" ? 24 : range === "6h" ? 6 : 1 + ); const filtered = useMemo(() => { return endpoints.filter((ep) => { if (!ep.route.toLowerCase().includes(search.toLowerCase())) return false; if (methodFilter !== "ALL" && ep.method !== methodFilter) return false; - if (statusFilter.size > 0) { - const matched = [...statusFilter].some((label) => { - const range = STATUS_RANGES.find((r) => r.label === label); - return range && ep.errorRate >= 0; // status filter is approximate via errorRate - }); - // For mock data we don't have exact status, use errorRate as proxy - // 5xx → errorRate > 5, 4xx → 1–5%, 2xx → < 1% - if (statusFilter.has("2xx") && !statusFilter.has("4xx") && !statusFilter.has("5xx")) { - if (ep.errorRate >= 1) return false; - } - if (statusFilter.has("4xx") && !statusFilter.has("2xx") && !statusFilter.has("5xx")) { - if (ep.errorRate < 1 || ep.errorRate >= 5) return false; - } - if (statusFilter.has("5xx") && !statusFilter.has("2xx") && !statusFilter.has("4xx")) { - if (ep.errorRate < 5) return false; - } - void matched; - } - if (maxLatency !== "") { - const threshold = parseInt(maxLatency, 10); - if (!isNaN(threshold) && ep.p99 > threshold) return false; - } return true; }); - }, [endpoints, search, methodFilter, statusFilter, maxLatency]); - - const summary = useMemo(() => ({ - healthy: endpoints.filter((e) => e.healthScore >= 80).length, - warning: endpoints.filter((e) => e.healthScore >= 50 && e.healthScore < 80).length, - critical: endpoints.filter((e) => e.healthScore < 50).length, - }), [endpoints]); - - function toggleStatusFilter(label: string) { - setStatusFilter((prev) => { - const next = new Set(prev); - if (next.has(label)) { next.delete(label); } else { next.add(label); } - return next; - }); - } - - function handleCompareSelect(ep: Endpoint) { - setCompareSelection((prev) => { - if (prev.some((e) => e.id === ep.id)) return prev.filter((e) => e.id !== ep.id); - if (prev.length >= 2) return [prev[1]!, ep]; - return [...prev, ep]; - }); - } - - function exitCompare() { - setCompareMode(false); - setCompareSelection([]); - } - - const showingComparison = compareMode && compareSelection.length === 2; + }, [endpoints, search, methodFilter]); + + const summary = useMemo( + () => ({ + healthy: endpoints.filter((e) => e.healthScore >= 80).length, + warning: endpoints.filter((e) => e.healthScore >= 50 && e.healthScore < 80).length, + critical: endpoints.filter((e) => e.healthScore < 50).length, + }), + [endpoints] + ); return (
@@ -111,42 +54,9 @@ export default function EndpointsPage() { {summary.warning} Warning {summary.critical} Critical
-
- {filtered.length} endpoints - -
+ {filtered.length} endpoints
- {/* Compare mode hint */} - {compareMode && !showingComparison && ( -
- - {compareSelection.length === 0 - ? "Select 2 endpoints to compare" - : `${compareSelection.length}/2 selected — pick one more`} - {compareSelection.length > 0 && ( - - )} -
- )} - - {/* Comparison panel */} - {showingComparison && ( - - )} - {/* Filter bar */}
{/* Route search */} @@ -175,65 +85,10 @@ export default function EndpointsPage() { ))}
- - {/* Status range filter */} -
- {STATUS_RANGES.map(({ label }) => ( - - ))} -
- - {/* Latency threshold */} -
- p99 < - setMaxLatency(e.target.value)} - className="bg-transparent text-xs text-[#F5F5F5] placeholder-[#808080] outline-none w-14 text-right tabular-nums" - /> - ms - {maxLatency && ( - - )} -
- {/* Table + drawer */} -
- e.id) : []} - onSelect={(ep) => { - if (compareMode) { - handleCompareSelect(ep); - } else { - setSelected(ep.id === selected?.id ? null : ep); - } - }} - /> -
+ - - {!compareMode && setSelected(null)} />} ); } diff --git a/app/(dashboard)/traces/page.tsx b/app/(dashboard)/traces/page.tsx deleted file mode 100644 index 82155ae..0000000 --- a/app/(dashboard)/traces/page.tsx +++ /dev/null @@ -1,228 +0,0 @@ -"use client"; - -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"; - -type DurationFilter = "any" | "<100" | "100-500" | "500-1000" | ">1000"; - -const DURATION_OPTIONS: { label: string; value: DurationFilter }[] = [ - { label: "Any", value: "any" }, - { label: "< 100ms", value: "<100" }, - { label: "100–500ms", value: "100-500" }, - { label: "500ms–1s", value: "500-1000" }, - { label: "> 1s", value: ">1000" }, -]; - -function matchesDuration(ms: number, filter: DurationFilter): boolean { - switch (filter) { - case "<100": return ms < 100; - case "100-500": return ms >= 100 && ms < 500; - case "500-1000": return ms >= 500 && ms < 1000; - case ">1000": return ms >= 1000; - default: return true; - } -} - -type ViewMode = "flamegraph" | "servicemap"; - -export default function TracesPage() { - const { activeProject } = useProjects(); - 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 - // list is empty (e.g. a real project with no spans, or after switching - // projects), and pick the first trace when the current one is gone. - useEffect(() => { - if (traces.length === 0) { - setSelectedTrace(null); - } else if (!selectedTrace || !traces.some((t) => t.id === selectedTrace.id)) { - setSelectedTrace(traces[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [traces]); - const [selectedSpan, setSelectedSpan] = useState(null); - const [search, setSearch] = useState(""); - const [durationFilter, setDurationFilter] = useState("any"); - const [criticalPath, setCriticalPath] = useState(false); - const [viewMode, setViewMode] = useState("flamegraph"); - - const filtered = useMemo(() => traces.filter((t) => { - const matchSearch = t.id.includes(search) || t.endpoint.includes(search) || t.service.includes(search); - const matchDuration = matchesDuration(t.totalDuration, durationFilter); - return matchSearch && matchDuration; - }), [traces, search, durationFilter]); - - return ( -
- } - /> - -
- {/* Trace list */} -
- {/* Search */} -
-
- - setSearch(e.target.value)} - className="bg-transparent text-[10px] text-[#F5F5F5] placeholder-[#808080] outline-none flex-1" - /> -
- {/* Duration filter */} -
- {DURATION_OPTIONS.map((opt) => ( - - ))} -
-
- -
- {filtered.map((trace) => ( - - ))} - {filtered.length === 0 && ( -
No traces match
- )} -
-
- - {/* Flame graph + detail */} -
- {selectedTrace ? ( - <> - {/* Trace header + toolbar */} -
-
- Trace - {selectedTrace.id} -
-
- {selectedTrace.spans.length} spans - {timeAgo(selectedTrace.timestamp)} - - {selectedTrace.status} - - - {/* View toggles */} -
- {/* Critical path toggle (only for flame graph) */} - {viewMode === "flamegraph" && ( - - )} - - {/* Flame / Map toggle */} -
- - -
-
-
- - {viewMode === "flamegraph" ? ( - - ) : ( - - )} - - {viewMode === "flamegraph" && ( - setSelectedSpan(null)} /> - )} - - ) : ( -
- Select a trace to view the flame graph -
- )} -
-
-
- ); -} diff --git a/app/api/projects/[id]/status-page/route.ts b/app/api/projects/[id]/status-page/route.ts deleted file mode 100644 index ac43f58..0000000 --- a/app/api/projects/[id]/status-page/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/layout.tsx b/app/layout.tsx index 6c8e28a..cb8b0ba 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,7 +14,7 @@ import { ProjectProvider } from "@/components/providers/project-provider"; export const metadata: Metadata = { title: "Liveboard — API Observability", - description: "Real-time API observability, distributed traces, and intelligent alerts", + description: "Real-time API observability, live error tracking, and AI incident summaries", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/app/page.tsx b/app/page.tsx index 5f0f532..e84bb87 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,6 @@ import { Hero } from "@/components/landing/hero"; import { MarqueeStrip } from "@/components/landing/marquee-strip"; import { FeatureGrid } from "@/components/landing/feature-grid"; import { IncidentSection } from "@/components/landing/incident-section"; -import { StatusSection } from "@/components/landing/status-section"; import { StatsSection } from "@/components/landing/stats-section"; import { CtaSection } from "@/components/landing/cta-section"; import { Footer } from "@/components/landing/footer"; @@ -23,7 +22,6 @@ export default async function Home() { -