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**
-
-
-
-
-
-**Distributed Traces**
-
-
-
-
-
-**Alert Rules**
-
-
-
-
-
-
-
-**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}
-
-
✕
-
-
-
-
- {channel.type === "pagerduty" ? "Routing key" : "Webhook URL"}
-
- { 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"}
-
-
- )}
-
-
-
- {testing ? : }
- {testing ? "Sending…" : "Send Test"}
-
-
- {saving ? "Saving…" : "Save"}
-
- onDelete(channel.id)}
- className="ml-auto text-[10px] text-[#949494] hover:text-red transition-colors"
- >
- Delete
-
-
-
- );
-}
-
-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
- ✕
-
-
- setType(e.target.value as ChannelType)}
- className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1.5 text-xs text-[#F5F5F5] outline-none focus:border-blue"
- >
- {CHANNEL_TYPES.map((t) => {t.label} )}
-
- 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"
- />
-
- {saving ? "Adding…" : "Add channel"}
-
-
- );
-}
-
-// ─── 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) => (
- setActiveTab(tab)}
- className={`px-3 py-1.5 text-xs rounded capitalize font-medium transition-colors ${
- activeTab === tab ? "bg-blue text-white" : "text-[#949494] hover:text-[#888]"
- }`}
- >
- {tab}
-
- ))}
-
-
- {/* ── Rules tab ── */}
- {activeTab === "rules" && (
- <>
-
-
- {/* Enhanced rule builder */}
-
-
Rule Builder
-
- {/* Main condition row */}
-
- Alert when
- setMetric(e.target.value)}
- className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1 text-xs text-[#F5F5F5] outline-none focus:border-blue transition-colors"
- >
- Error Rate
- p99 Latency
- Requests/min
- Apdex Score
-
- setOperator(e.target.value)}
- className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1 text-xs text-[#F5F5F5] outline-none focus:border-blue transition-colors"
- >
- exceeds
- drops below
- equals
-
- 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) => (
- setSeverity(s.value)}
- className={cn(
- "px-2.5 py-1 rounded text-[10px] font-semibold border transition-colors",
- severity === s.value
- ? "border-current"
- : "border-[#1E1E1E] text-[#949494] hover:text-[#888]"
- )}
- style={severity === s.value ? { color: s.color, borderColor: s.color, backgroundColor: `${s.color}18` } : {}}
- >
- {s.label}
-
- ))}
-
-
-
Send to
-
setChannelName(e.target.value)}
- className="bg-[#111] border border-[#2A2A2A] rounded px-2 py-1 text-xs text-[#F5F5F5] outline-none focus:border-blue transition-colors"
- >
- {channels.filter((c) => c.enabled).length === 0 && (
- No channels — add one first
- )}
- {channels.filter((c) => c.enabled).map((c) => (
- {c.name}
- ))}
-
-
-
- {creating ? : }
- {creating ? "Creating…" : "Create Rule"}
-
-
-
- {/* 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"}
-
- )}
-
-
-
-
setConfiguringId(configuringId === ch.id ? null : ch.id)}
- >
- Configure
-
-
- {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)} />
- ) : (
- setAddingChannel(true)}
- 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
-
- )}
-
- )}
-
- {/* ── 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
- (compareMode ? exitCompare() : setCompareMode(true))}
- >
-
- {compareMode ? "Exit Compare" : "Compare"}
-
-
+ {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 && (
- setCompareSelection([])} className="ml-auto text-[#808080] hover:text-[#888]">
-
-
- )}
-
- )}
-
- {/* Comparison panel */}
- {showingComparison && (
-
- )}
-
{/* Filter bar */}
{/* Route search */}
@@ -175,65 +85,10 @@ export default function EndpointsPage() {
))}
-
- {/* Status range filter */}
-
- {STATUS_RANGES.map(({ label }) => (
- toggleStatusFilter(label)}
- className={`px-2 py-1 text-[10px] font-bold rounded transition-colors ${
- statusFilter.has(label)
- ? label === "2xx"
- ? "bg-green/20 text-green"
- : label === "4xx"
- ? "bg-yellow/20 text-yellow"
- : "bg-red/20 text-red"
- : "text-[#949494] hover:text-[#888]"
- }`}
- >
- {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 && (
- setMaxLatency("")} className="text-[#808080] hover:text-[#949494]">
-
-
- )}
-
- {/* 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) => (
- setDurationFilter(opt.value)}
- className={cn(
- "px-2 py-0.5 text-[9px] rounded border transition-colors",
- durationFilter === opt.value
- ? "border-blue bg-blue/10 text-blue"
- : "border-[#1E1E1E] text-[#808080] hover:text-[#888] hover:border-[#2A2A2A]"
- )}
- >
- {opt.label}
-
- ))}
-
-
-
-
- {filtered.map((trace) => (
-
{ setSelectedTrace(trace); setSelectedSpan(null); }}
- className={cn(
- "w-full p-3 text-left hover:bg-[#151515] transition-colors",
- selectedTrace?.id === trace.id && "bg-blue-dim border-l-2 border-l-blue"
- )}
- >
-
- {trace.id}
-
- {trace.status}
-
-
- {trace.endpoint}
-
- {timeAgo(trace.timestamp)}
- 1000 ? "text-red" : trace.totalDuration > 500 ? "text-yellow" : "text-green"
- )}>
- {formatMs(trace.totalDuration)}
-
-
-
- ))}
- {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" && (
-
setCriticalPath((v) => !v)}
- className={cn(
- "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-[#808080] hover:text-[#888] hover:border-[#2A2A2A]"
- )}
- >
-
- Critical Path
-
- )}
-
- {/* Flame / Map toggle */}
-
- 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-[#949494] hover:text-[#888]"
- )}
- >
-
- Flame
-
- 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-[#949494] hover:text-[#888]"
- )}
- >
-
- Map
-
-
-
-
-
- {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() {
-
diff --git a/app/status/[slug]/page.tsx b/app/status/[slug]/page.tsx
deleted file mode 100644
index de00649..0000000
--- a/app/status/[slug]/page.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-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 (
-
-
-
-
-
-
-
-
-
-
- Components
-
-
- {services.map((service) => (
-
- ))}
- {services.length === 0 && (
-
No components to report yet.
- )}
-
-
-
-
-
-
- Incident History
-
-
- {incidents.filter((i) => !i.resolved).length} active
-
-
-
- {incidents.map((incident) => (
-
- ))}
-
-
-
-
-
-
- Powered by LiveBoard · status.liveboard.dev
-
-
-
- );
-}
diff --git a/app/status/confirm/[token]/page.tsx b/app/status/confirm/[token]/page.tsx
deleted file mode 100644
index 8f1472a..0000000
--- a/app/status/confirm/[token]/page.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-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
deleted file mode 100644
index 40442e8..0000000
--- a/app/status/page.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { ExternalLink } from "lucide-react";
-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 { useIncidents, useServices } from "@/hooks/use-data";
-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();
- // 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");
-
- return (
-
- {/* Top bar */}
-
-
-
- {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 */}
-
-
- {/* 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
-
-
-
- {incidents.map((incident) => (
-
- ))}
-
-
-
- {/* Subscribe */}
- {activeProject?.statusPageEnabled && activeProject.publicSlug ? (
-
- ) : null}
-
-
- Powered by{" "}
- LiveBoard · status.liveboard.dev
-
-
-
- );
-}
diff --git a/app/status/unsubscribe/[token]/page.tsx b/app/status/unsubscribe/[token]/page.tsx
deleted file mode 100644
index 651419f..0000000
--- a/app/status/unsubscribe/[token]/page.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-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 UnsubscribePage({
- params,
-}: {
- params: Promise<{ token: string }>;
-}) {
- const { token } = await params;
- const res = await fetch(
- `${API_URL}/v1/public/status/subscriptions/${encodeURIComponent(token)}/unsubscribe`,
- { cache: "no-store" },
- );
- const data = (await res.json()) as { ok: boolean; detail: string };
-
- return (
-
-
-
- {data.ok ? (
-
- ) : (
-
- )}
-
{data.detail}
-
-
- );
-}
diff --git a/apps/api/api/routes/alerts.py b/apps/api/api/routes/alerts.py
deleted file mode 100644
index 77cd875..0000000
--- a/apps/api/api/routes/alerts.py
+++ /dev/null
@@ -1,306 +0,0 @@
-"""
-Alert rules CRUD + alert history endpoints.
-All routes require x-api-key auth via resolve_project_id dependency.
-"""
-from __future__ import annotations
-
-import logging
-from datetime import datetime
-
-import asyncpg
-from fastapi import APIRouter, Depends, HTTPException, Response
-
-from api.deps import resolve_project_id, get_db, scoped_conn
-from api.schemas import (
- AlertHistoryOut,
- AlertRuleOut,
- ChannelIn,
- ChannelOut,
- ChannelTestResponse,
- ChannelUpdate,
- CreateAlertRuleRequest,
- UpdateAlertRuleRequest,
-)
-from core.notify import deliver
-
-router = APIRouter(prefix="/v1", tags=["alerts"])
-logger = logging.getLogger(__name__)
-
-
-def _rule_row_to_out(row: asyncpg.Record) -> AlertRuleOut:
- return AlertRuleOut(
- id=str(row["id"]),
- name=row["name"],
- metric=row["metric"],
- operator=row["operator"],
- threshold=row["threshold"],
- window=row["window_min"],
- severity=row["severity"],
- channel=row["channel"],
- status=row["status"],
- enabled=row["enabled"],
- last_triggered=row["last_triggered"].isoformat() if row["last_triggered"] else None,
- )
-
-
-# ─── List ────────────────────────────────────────────────────────────────────
-
-@router.get("/alert-rules", response_model=list[AlertRuleOut])
-async def list_alert_rules(
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(scoped_conn),
-) -> list[AlertRuleOut]:
- rows = await conn.fetch(
- """
- SELECT id, name, metric, operator, threshold, window_min,
- severity, channel, status, enabled, last_triggered
- FROM alert_rules
- WHERE project_id = $1
- ORDER BY created_at ASC
- """,
- project_id,
- )
- return [_rule_row_to_out(r) for r in rows]
-
-
-# ─── Create ──────────────────────────────────────────────────────────────────
-
-@router.post("/alert-rules", response_model=AlertRuleOut, status_code=201)
-async def create_alert_rule(
- body: CreateAlertRuleRequest,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> AlertRuleOut:
- row = await conn.fetchrow(
- """
- INSERT INTO alert_rules
- (project_id, name, metric, operator, threshold, window_min, severity, channel)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
- RETURNING id, name, metric, operator, threshold, window_min,
- severity, channel, status, enabled, last_triggered
- """,
- project_id,
- body.name,
- body.metric,
- body.operator,
- body.threshold,
- body.window,
- body.severity,
- body.channel,
- )
- return _rule_row_to_out(row)
-
-
-# ─── Update ──────────────────────────────────────────────────────────────────
-
-@router.patch("/alert-rules/{rule_id}", response_model=AlertRuleOut)
-async def update_alert_rule(
- rule_id: str,
- body: UpdateAlertRuleRequest,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> AlertRuleOut:
- existing = await conn.fetchrow(
- "SELECT id FROM alert_rules WHERE id = $1 AND project_id = $2",
- rule_id,
- project_id,
- )
- if not existing:
- raise HTTPException(status_code=404, detail="Alert rule not found")
-
- updates = body.model_dump(exclude_none=True)
- if not updates:
- row = await conn.fetchrow(
- """
- SELECT id, name, metric, operator, threshold, window_min,
- severity, channel, status, enabled, last_triggered
- FROM alert_rules WHERE id = $1
- """,
- rule_id,
- )
- return _rule_row_to_out(row)
-
- # Rename window → window_min for DB column
- if "window" in updates:
- updates["window_min"] = updates.pop("window")
-
- set_clauses = ", ".join(f"{col} = ${i + 2}" for i, col in enumerate(updates))
- values = list(updates.values())
-
- row = await conn.fetchrow(
- f"""
- UPDATE alert_rules
- SET {set_clauses}, updated_at = now()
- WHERE id = $1
- RETURNING id, name, metric, operator, threshold, window_min,
- severity, channel, status, enabled, last_triggered
- """,
- rule_id,
- *values,
- )
- return _rule_row_to_out(row)
-
-
-# ─── Delete ──────────────────────────────────────────────────────────────────
-
-@router.delete("/alert-rules/{rule_id}", status_code=204, response_model=None, response_class=Response)
-async def delete_alert_rule(
- rule_id: str,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> Response:
- result = await conn.execute(
- "DELETE FROM alert_rules WHERE id = $1 AND project_id = $2",
- rule_id,
- project_id,
- )
- if result == "DELETE 0":
- raise HTTPException(status_code=404, detail="Alert rule not found")
- return Response(status_code=204)
-
-
-# ─── History ─────────────────────────────────────────────────────────────────
-
-def _duration_str(fired_at: datetime, resolved_at: datetime | None) -> str:
- if resolved_at is None:
- return "ongoing"
- delta = int((resolved_at - fired_at).total_seconds())
- if delta < 60:
- return f"{delta}s"
- return f"{delta // 60} min"
-
-
-@router.get("/alert-history", response_model=list[AlertHistoryOut])
-async def get_alert_history(
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(scoped_conn),
-) -> list[AlertHistoryOut]:
- rows = await conn.fetch(
- """
- SELECT id, rule_name, fired_at, resolved_at, channel
- FROM alert_history
- WHERE project_id = $1
- ORDER BY fired_at DESC
- LIMIT 50
- """,
- project_id,
- )
- return [
- AlertHistoryOut(
- id=str(row["id"]),
- rule_name=row["rule_name"],
- fired_at=row["fired_at"].isoformat(),
- resolved_at=row["resolved_at"].isoformat() if row["resolved_at"] else None,
- channel=row["channel"],
- resolved=row["resolved_at"] is not None,
- duration=_duration_str(row["fired_at"], row["resolved_at"]),
- )
- for row in rows
- ]
-
-
-# ─── Alert channels (real delivery targets) ──────────────────────────────────
-
-def _channel_row_to_out(row: asyncpg.Record) -> ChannelOut:
- return ChannelOut(
- id=str(row["id"]),
- type=row["type"],
- name=row["name"],
- webhook_url=row["webhook_url"],
- enabled=row["enabled"],
- last_delivery_at=row["last_delivery_at"].isoformat() if row["last_delivery_at"] else None,
- last_delivery_ok=row["last_delivery_ok"],
- created_at=row["created_at"].isoformat() if row["created_at"] else "",
- )
-
-
-@router.get("/channels", response_model=list[ChannelOut])
-async def list_channels(
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(scoped_conn),
-) -> list[ChannelOut]:
- rows = await conn.fetch(
- "SELECT * FROM alert_channels WHERE project_id = $1 ORDER BY created_at",
- project_id,
- )
- return [_channel_row_to_out(r) for r in rows]
-
-
-@router.post("/channels", response_model=ChannelOut, status_code=201)
-async def create_channel(
- body: ChannelIn,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> ChannelOut:
- row = await conn.fetchrow(
- """
- INSERT INTO alert_channels (project_id, type, name, webhook_url, enabled)
- VALUES ($1::uuid, $2, $3, $4, $5)
- RETURNING *
- """,
- project_id, body.type, body.name, body.webhook_url, body.enabled,
- )
- return _channel_row_to_out(row)
-
-
-@router.patch("/channels/{channel_id}", response_model=ChannelOut)
-async def update_channel(
- channel_id: str,
- body: ChannelUpdate,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> ChannelOut:
- row = await conn.fetchrow(
- """
- UPDATE alert_channels
- SET name = COALESCE($3, name),
- webhook_url = COALESCE($4, webhook_url),
- enabled = COALESCE($5, enabled)
- WHERE id = $1::uuid AND project_id = $2::uuid
- RETURNING *
- """,
- channel_id, project_id, body.name, body.webhook_url, body.enabled,
- )
- if row is None:
- raise HTTPException(status_code=404, detail="Channel not found")
- return _channel_row_to_out(row)
-
-
-@router.delete("/channels/{channel_id}", status_code=204, response_model=None, response_class=Response)
-async def delete_channel(
- channel_id: str,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> Response:
- result = await conn.execute(
- "DELETE FROM alert_channels WHERE id = $1::uuid AND project_id = $2::uuid",
- channel_id, project_id,
- )
- if result == "DELETE 0":
- raise HTTPException(status_code=404, detail="Channel not found")
- return Response(status_code=204)
-
-
-@router.post("/channels/{channel_id}/test", response_model=ChannelTestResponse)
-async def test_channel(
- channel_id: str,
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(get_db),
-) -> ChannelTestResponse:
- row = await conn.fetchrow(
- "SELECT type, webhook_url FROM alert_channels WHERE id = $1::uuid AND project_id = $2::uuid",
- channel_id, project_id,
- )
- if row is None:
- raise HTTPException(status_code=404, detail="Channel not found")
- ok, detail = await deliver(
- row["type"], row["webhook_url"],
- title="Liveboard test alert",
- summary="This is a test message from Liveboard. If you can see this, delivery works.",
- severity="info",
- )
- await conn.execute(
- "UPDATE alert_channels SET last_delivery_at = now(), last_delivery_ok = $2 WHERE id = $1::uuid",
- channel_id, ok,
- )
- return ChannelTestResponse(ok=ok, detail=detail)
diff --git a/apps/api/api/routes/internal.py b/apps/api/api/routes/internal.py
index 861e630..6afcaaa 100644
--- a/apps/api/api/routes/internal.py
+++ b/apps/api/api/routes/internal.py
@@ -54,8 +54,6 @@ class ProjectDetail(BaseModel):
org_id: str
api_key_masked: str
created_at: str
- status_page_enabled: bool
- public_slug: str | None
class ProvisionResponse(BaseModel):
@@ -80,16 +78,6 @@ class RotatedKeyResponse(BaseModel):
new_api_key: str
-class StatusPageToggleRequest(BaseModel):
- email: str
- enabled: bool
-
-
-class StatusPageSettingsResponse(BaseModel):
- enabled: bool
- public_slug: str | None
-
-
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _slugify(text: str) -> str:
@@ -260,7 +248,6 @@ async def list_projects(
rows = await db.fetch(
"""
SELECT p.id, p.name, p.org_id, p.created_at,
- p.status_page_enabled, p.public_slug,
(SELECT k.prefix FROM api_keys k
WHERE k.project_id = p.id AND k.revoked_at IS NULL
ORDER BY k.created_at DESC LIMIT 1) AS prefix
@@ -279,8 +266,6 @@ async def list_projects(
org_id=str(r["org_id"]),
api_key_masked=_mask(r["prefix"]),
created_at=r["created_at"].isoformat() if r["created_at"] else "",
- status_page_enabled=r["status_page_enabled"],
- public_slug=r["public_slug"],
)
for r in rows
]
@@ -331,60 +316,6 @@ async def rotate_key(
return RotatedKeyResponse(project_id=project_id, new_api_key=raw)
-async def _generate_public_slug(db: asyncpg.Connection, name: str) -> str:
- """A `_slugify(name)` base is not unique enough on its own — every new
- signup defaults to a project named 'My API' (see _create_project below),
- so append a short random suffix and retry on the rare collision."""
- base = _slugify(name)
- for _ in range(5):
- candidate = f"{base}-{secrets.token_urlsafe(4).lower().rstrip('=-_')}"
- exists = await db.fetchval("SELECT 1 FROM projects WHERE public_slug = $1", candidate)
- if not exists:
- return candidate
- raise HTTPException(status_code=500, detail="Could not generate a unique status page slug")
-
-
-@router.patch("/projects/{project_id}/status-page", response_model=StatusPageSettingsResponse)
-async def set_status_page(
- project_id: str,
- body: StatusPageToggleRequest,
- db: asyncpg.Connection = Depends(get_db),
- _: None = Depends(authenticate_internal),
-) -> StatusPageSettingsResponse:
- """
- Enable/disable the public status page for a project. Enabling mints a
- public_slug the first time only — disabling keeps it, so re-enabling
- restores the same /status/{slug} link.
- """
- await _require_member(db, body.email, project_id)
-
- if body.enabled:
- row = await db.fetchrow(
- "SELECT name, public_slug FROM projects WHERE id = $1::uuid", project_id
- )
- if row is None:
- raise HTTPException(status_code=404, detail="Project not found")
- slug = row["public_slug"] or await _generate_public_slug(db, row["name"])
- await db.execute(
- "UPDATE projects SET status_page_enabled = TRUE, public_slug = $2 WHERE id = $1::uuid",
- project_id,
- slug,
- )
- return StatusPageSettingsResponse(enabled=True, public_slug=slug)
-
- row = await db.fetchrow(
- """
- UPDATE projects SET status_page_enabled = FALSE
- WHERE id = $1::uuid
- RETURNING public_slug
- """,
- project_id,
- )
- if row is None:
- raise HTTPException(status_code=404, detail="Project not found")
- return StatusPageSettingsResponse(enabled=False, public_slug=row["public_slug"])
-
-
class RealtimeTokenRequest(BaseModel):
project_id: str
diff --git a/apps/api/api/routes/public_status.py b/apps/api/api/routes/public_status.py
deleted file mode 100644
index 99187d2..0000000
--- a/apps/api/api/routes/public_status.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""
-Public, unauthenticated status-page surface.
-
-Deliberately outside the resolve_project_id / scoped_conn read plane used by
-the rest of api/routes/*.py — there is no signed-in caller here, only a
-project's own public_slug. A project only becomes reachable through this
-router once its owner flips status_page_enabled on (see
-api.routes.internal.set_status_page).
-"""
-from __future__ import annotations
-
-import logging
-import secrets
-from datetime import datetime, timedelta, timezone
-
-import asyncpg
-from fastapi import APIRouter, Depends, HTTPException
-
-from api.deps import get_db
-from api.schemas import (
- PublicStatusOut,
- SubscribeRequest,
- SubscribeResponse,
- SubscriptionStatusOut,
-)
-from api.routes.query import fetch_incidents, fetch_services
-from core.config import settings
-from core.email import confirmation_email, send
-
-router = APIRouter(prefix="/v1/public", tags=["public-status"])
-logger = logging.getLogger(__name__)
-
-_RESEND_COOLDOWN = timedelta(seconds=60)
-
-
-async def _resolve_public_project(conn: asyncpg.Connection, slug: str) -> asyncpg.Record:
- row = await conn.fetchrow(
- "SELECT id, name FROM projects WHERE public_slug = $1 AND status_page_enabled = TRUE",
- slug,
- )
- if row is None:
- raise HTTPException(status_code=404, detail="Status page not found")
- return row
-
-
-@router.get("/status/{slug}", response_model=PublicStatusOut)
-async def public_status(
- slug: str,
- conn: asyncpg.Connection = Depends(get_db),
-) -> PublicStatusOut:
- project = await _resolve_public_project(conn, slug)
- project_id = str(project["id"])
- services = await fetch_services(conn, project_id)
- incidents = await fetch_incidents(conn, project_id)
- return PublicStatusOut(project_name=project["name"], services=services, incidents=incidents)
-
-
-@router.post("/status/{slug}/subscribe", response_model=SubscribeResponse)
-async def subscribe(
- slug: str,
- body: SubscribeRequest,
- conn: asyncpg.Connection = Depends(get_db),
-) -> SubscribeResponse:
- """
- Double opt-in: creates/refreshes an unconfirmed row and emails a confirm
- link. Never reveals whether the address was already subscribed — always
- returns {ok: true} — so this can't be used to enumerate subscribers.
- """
- project = await _resolve_public_project(conn, slug)
-
- existing = await conn.fetchrow(
- "SELECT confirmed_at, unsubscribed_at, created_at "
- "FROM status_subscribers WHERE project_id = $1 AND email = $2",
- project["id"],
- body.email,
- )
- already_active = bool(existing and existing["confirmed_at"] and not existing["unsubscribed_at"])
- recently_issued = bool(
- existing
- and existing["confirmed_at"] is None
- and (datetime.now(timezone.utc) - existing["created_at"]) < _RESEND_COOLDOWN
- )
- if already_active or recently_issued:
- return SubscribeResponse(ok=True)
-
- token = secrets.token_urlsafe(24)
- await conn.execute(
- """
- INSERT INTO status_subscribers (project_id, email, confirm_token)
- VALUES ($1, $2, $3)
- ON CONFLICT (project_id, email) DO UPDATE
- SET confirm_token = $3, confirmed_at = NULL, unsubscribed_at = NULL, created_at = now()
- """,
- project["id"],
- body.email,
- token,
- )
-
- confirm_url = f"{settings.public_app_url}/status/confirm/{token}"
- subject, html = confirmation_email(confirm_url)
- ok, detail = await send(body.email, subject, html)
- if not ok:
- logger.warning("Confirmation email to subscriber failed: %s", detail)
- return SubscribeResponse(ok=True)
-
-
-@router.get("/status/subscriptions/{token}/confirm", response_model=SubscriptionStatusOut)
-async def confirm_subscription(
- token: str,
- conn: asyncpg.Connection = Depends(get_db),
-) -> SubscriptionStatusOut:
- row = await conn.fetchrow(
- """
- UPDATE status_subscribers SET confirmed_at = now()
- WHERE confirm_token = $1 AND unsubscribed_at IS NULL
- RETURNING email
- """,
- token,
- )
- if row is None:
- return SubscriptionStatusOut(ok=False, detail="This confirmation link is invalid or expired.")
- return SubscriptionStatusOut(ok=True, detail=f"Subscribed — you'll get incident alerts at {row['email']}.")
-
-
-@router.get("/status/subscriptions/{token}/unsubscribe", response_model=SubscriptionStatusOut)
-async def unsubscribe(
- token: str,
- conn: asyncpg.Connection = Depends(get_db),
-) -> SubscriptionStatusOut:
- row = await conn.fetchrow(
- """
- UPDATE status_subscribers SET unsubscribed_at = now()
- WHERE confirm_token = $1
- RETURNING email
- """,
- token,
- )
- if row is None:
- return SubscriptionStatusOut(ok=False, detail="This unsubscribe link is invalid.")
- return SubscriptionStatusOut(ok=True, detail=f"Unsubscribed {row['email']}.")
diff --git a/apps/api/api/routes/query.py b/apps/api/api/routes/query.py
index a725664..dfea1b2 100644
--- a/apps/api/api/routes/query.py
+++ b/apps/api/api/routes/query.py
@@ -5,7 +5,6 @@
from __future__ import annotations
import datetime
-import json
import logging
import asyncpg
@@ -15,10 +14,7 @@
from api.schemas import (
EndpointStat,
IncidentOut,
- ServiceStatusOut,
- SpanOut,
TimeseriesPoint,
- TraceOut,
)
router = APIRouter(prefix="/v1", tags=["query"])
@@ -171,8 +167,7 @@ async def get_endpoints(
# ─── Incidents ───────────────────────────────────────────────────────────────
async def fetch_incidents(conn: asyncpg.Connection, project_id: str) -> list[IncidentOut]:
- """Recent incidents from the AI anomaly detection worker (Phase 5). Shared by the
- authenticated dashboard route and the public status-page route."""
+ """Recent incidents written by the AI anomaly detection worker (Phase 5)."""
rows = await conn.fetch(
"""
SELECT id, severity, title, summary, endpoint, resolved, created_at
@@ -203,214 +198,3 @@ async def get_incidents(
conn: asyncpg.Connection = Depends(scoped_conn),
) -> list[IncidentOut]:
return await fetch_incidents(conn, project_id)
-
-
-# ─── Traces ──────────────────────────────────────────────────────────────────
-
-@router.get("/traces", response_model=list[TraceOut])
-async def get_traces(
- hours: int = Query(24, ge=1, le=168),
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(scoped_conn),
-) -> list[TraceOut]:
- """
- Recent distributed traces assembled from the spans table, within the
- last `hours` (default 24, matching Endpoints/Overview's picker). This
- used to hardcode 24h with no way to widen it and no indication to the
- viewer that a window even existed — a project with real spans older than
- a day would just show an empty page with no explanation.
- """
- rows = await conn.fetch(
- """
- WITH recent_traces AS (
- SELECT DISTINCT trace_id
- FROM spans
- WHERE project_id = $1
- AND start_time >= now() - ($2 * INTERVAL '1 hour')
- LIMIT 20
- )
- SELECT
- s.span_id,
- s.trace_id,
- s.parent_id,
- s.service_name,
- s.operation,
- s.start_time,
- s.duration_ms,
- s.status_code,
- s.tags,
- MIN(s.start_time) OVER (PARTITION BY s.trace_id) AS trace_start,
- SUM(s.duration_ms) OVER (PARTITION BY s.trace_id) AS total_duration,
- BOOL_OR(s.status_code >= 500) OVER (PARTITION BY s.trace_id) AS has_error
- FROM spans s
- JOIN recent_traces rt ON rt.trace_id = s.trace_id
- WHERE s.project_id = $1
- ORDER BY trace_start DESC, s.start_time
- """,
- project_id,
- hours,
- )
-
- # Group spans into traces
- traces: dict[str, dict] = {}
- for row in rows:
- tid = str(row["trace_id"])
- if tid not in traces:
- traces[tid] = {
- "id": tid,
- "trace_start": row["trace_start"],
- "total_duration": int(row["total_duration"] or 0),
- "has_error": bool(row["has_error"]),
- "spans": [],
- }
- trace_start_ms = int(row["trace_start"].timestamp() * 1000)
- span_start_ms = (
- int(row["start_time"].timestamp() * 1000) if row["start_time"] else trace_start_ms
- )
- traces[tid]["spans"].append(
- SpanOut(
- id=str(row["span_id"]),
- trace_id=tid,
- parent_id=str(row["parent_id"]) if row["parent_id"] else None,
- service=row["service_name"] or "unknown",
- name=row["operation"] or "unknown",
- start_time=max(0, span_start_ms - trace_start_ms),
- duration=int(row["duration_ms"] or 0),
- status="error" if (row["status_code"] and row["status_code"] >= 500) else "ok",
- # asyncpg returns JSONB as a string — parse it back to a dict.
- tags=(json.loads(row["tags"]) if isinstance(row["tags"], str) else (row["tags"] or {})),
- )
- )
-
- result: list[TraceOut] = []
- for t in traces.values():
- spans = t["spans"]
- root = next((s for s in spans if s.parent_id is None), spans[0] if spans else None)
- if not root:
- continue
- result.append(
- TraceOut(
- id=t["id"],
- root_span=root.id,
- service=root.service,
- endpoint=root.name,
- total_duration=t["total_duration"],
- timestamp=t["trace_start"].isoformat(),
- status="error" if t["has_error"] else "ok",
- spans=spans,
- )
- )
- return result
-
-
-# ─── Services ────────────────────────────────────────────────────────────────
-
-def _service_name(prefix: str) -> str:
- """Map a route prefix to a human-readable service name."""
- segment = prefix.strip("/").split("/")[-1].lower()
- _map = {
- "auth": "Authentication",
- "users": "User Service",
- "user": "User Service",
- "products": "Product Catalog",
- "product": "Product Catalog",
- "orders": "Order Management",
- "order": "Order Management",
- "payments": "Payments",
- "payment": "Payments",
- "search": "Search",
- "webhooks": "Webhooks",
- "analytics": "Analytics",
- }
- return _map.get(segment, prefix or "API")
-
-
-async def fetch_services(conn: asyncpg.Connection, project_id: str) -> list[ServiceStatusOut]:
- """
- Service health derived from route-prefix groups in event data.
- Uptime bars represent up to 90 days; missing days default to 'up'.
- Shared by the authenticated dashboard route and the public status-page route.
- """
- rows = await conn.fetch(
- """
- SELECT
- '/' || split_part(route, '/', 2) AS prefix,
- time_bucket('1 day', time) AS day,
- AVG(duration_ms)::float AS avg_latency,
- (SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END)::float / COUNT(*)) AS error_rate
- FROM events
- WHERE project_id = $1
- AND time >= now() - INTERVAL '90 days'
- AND route IS NOT NULL
- AND route != ''
- GROUP BY prefix, day
- ORDER BY prefix, day
- """,
- project_id,
- )
-
- if not rows:
- return []
-
- by_prefix: dict[str, list[dict]] = {}
- for row in rows:
- p = row["prefix"] or "/unknown"
- if p not in by_prefix:
- by_prefix[p] = []
- by_prefix[p].append(
- {
- "day": row["day"].date(),
- "latency": row["avg_latency"] or 0.0,
- "error_rate": row["error_rate"] or 0.0,
- }
- )
-
- today = datetime.date.today()
- result: list[ServiceStatusOut] = []
-
- for i, (prefix, days) in enumerate(by_prefix.items()):
- bar_map: dict[datetime.date, str] = {
- d["day"]: (
- "down" if d["error_rate"] > 0.1
- else "degraded" if d["error_rate"] > 0.02
- else "up"
- )
- for d in days
- }
-
- uptime_bars: list[str] = [
- bar_map.get(today - datetime.timedelta(days=89 - j), "up")
- for j in range(90)
- ]
-
- up_count = sum(1 for b in uptime_bars if b == "up")
- uptime_pct = round(up_count / 90 * 100, 2)
-
- latest_bar = bar_map.get(today) or bar_map.get(today - datetime.timedelta(days=1), "up")
- current_status = {"up": "operational", "degraded": "degraded", "down": "major_outage"}[
- latest_bar
- ]
-
- recent = sorted(days, key=lambda d: d["day"], reverse=True)
- response_time = round(recent[0]["latency"], 1) if recent else 0.0
-
- result.append(
- ServiceStatusOut(
- id=f"svc_{i:02d}",
- name=_service_name(prefix),
- uptime90d=uptime_pct,
- current_status=current_status,
- response_time=response_time,
- uptime_bars=uptime_bars,
- )
- )
-
- return result[:10]
-
-
-@router.get("/services", response_model=list[ServiceStatusOut])
-async def get_services(
- project_id: str = Depends(resolve_project_id),
- conn: asyncpg.Connection = Depends(scoped_conn),
-) -> list[ServiceStatusOut]:
- return await fetch_services(conn, project_id)
diff --git a/apps/api/api/routes/spans.py b/apps/api/api/routes/spans.py
deleted file mode 100644
index e0ef40f..0000000
--- a/apps/api/api/routes/spans.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-Span ingest endpoint — POST /v1/spans
-
-Accepts a batch of OpenTelemetry-style spans and bulk-inserts them into
-the spans table. Auth is the same x-api-key header as the event ingest route.
-"""
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-
-import asyncpg
-from fastapi import APIRouter, Depends
-
-from api.deps import authenticate_project, get_db
-from api.schemas import SpanBatchRequest, SpanBatchResponse
-
-router = APIRouter(prefix="/v1", tags=["spans"])
-logger = logging.getLogger(__name__)
-
-
-@router.post("/spans", response_model=SpanBatchResponse, status_code=202)
-async def ingest_spans(
- body: SpanBatchRequest,
- project_id: str = Depends(authenticate_project),
- conn: asyncpg.Connection = Depends(get_db),
-) -> SpanBatchResponse:
- """Bulk-insert a batch of spans. Duplicate span_ids are silently ignored."""
- records = []
- for s in body.spans:
- try:
- records.append((
- uuid.UUID(s.span_id),
- uuid.UUID(s.trace_id),
- uuid.UUID(s.parent_id) if s.parent_id else None,
- uuid.UUID(project_id),
- s.service_name,
- s.operation,
- s.start_time,
- s.duration_ms,
- s.status_code,
- json.dumps(s.tags),
- ))
- except (ValueError, AttributeError) as exc:
- logger.debug("Skipping malformed span %s: %s", s.span_id, exc)
- continue
-
- if not records:
- return SpanBatchResponse(accepted=0)
-
- await conn.executemany(
- """
- INSERT INTO spans
- (span_id, trace_id, parent_id, project_id,
- service_name, operation, start_time, duration_ms, status_code, tags)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
- ON CONFLICT (span_id) DO NOTHING
- """,
- records,
- )
-
- logger.info("Accepted %d spans for project=%s", len(records), project_id)
- return SpanBatchResponse(accepted=len(records))
diff --git a/apps/api/api/schemas.py b/apps/api/api/schemas.py
index dd66ecc..5000be0 100644
--- a/apps/api/api/schemas.py
+++ b/apps/api/api/schemas.py
@@ -80,171 +80,3 @@ class IncidentOut(BaseModel):
endpoint: str
timestamp: str
resolved: bool
-
-
-class SpanOut(BaseModel):
- id: str
- trace_id: str
- parent_id: Optional[str]
- service: str
- name: str
- start_time: int
- duration: int
- status: str
- tags: dict
-
-
-class TraceOut(BaseModel):
- id: str
- root_span: str
- service: str
- endpoint: str
- total_duration: int
- timestamp: str
- status: str
- spans: list[SpanOut]
-
-
-class ServiceStatusOut(BaseModel):
- id: str
- name: str
- uptime90d: float
- current_status: str
- response_time: float
- uptime_bars: list[str]
-
-
-# ─── Span ingest ─────────────────────────────────────────────────────────────
-
-class SpanIn(BaseModel):
- span_id: str
- trace_id: str
- parent_id: Optional[str] = None
- service_name: str = Field(default="unknown", max_length=100)
- operation: str = Field(..., min_length=1, max_length=200)
- start_time: datetime
- duration_ms: int = Field(..., ge=0)
- status_code: int = Field(default=200, ge=100, le=599)
- tags: dict = Field(default_factory=dict)
-
- @field_validator("start_time", mode="before")
- @classmethod
- def ensure_utc(cls, v: datetime) -> datetime:
- if isinstance(v, datetime) and v.tzinfo is None:
- return v.replace(tzinfo=timezone.utc)
- return v
-
-
-class SpanBatchRequest(BaseModel):
- spans: list[SpanIn] = Field(..., min_length=1, max_length=500)
-
-
-class SpanBatchResponse(BaseModel):
- accepted: int
-
-
-# ─── Alert rules ─────────────────────────────────────────────────────────────
-
-class AlertRuleOut(BaseModel):
- id: str
- name: str
- metric: str
- operator: str
- threshold: float
- window: int
- severity: str
- channel: str
- status: str
- enabled: bool
- last_triggered: Optional[str]
-
-
-class CreateAlertRuleRequest(BaseModel):
- name: str = Field(..., min_length=1, max_length=100)
- metric: str = Field(..., min_length=1, max_length=100)
- operator: Literal[">", "<", "=", "!="]
- threshold: float
- window: int = Field(..., ge=1, le=60)
- severity: Literal["critical", "warning", "info"] = "warning"
- channel: str = Field(default="", max_length=200)
-
-
-class UpdateAlertRuleRequest(BaseModel):
- name: Optional[str] = None
- metric: Optional[str] = None
- operator: Optional[Literal[">", "<", "=", "!="]] = None
- threshold: Optional[float] = None
- window: Optional[int] = Field(default=None, ge=1, le=60)
- severity: Optional[Literal["critical", "warning", "info"]] = None
- channel: Optional[str] = None
- enabled: Optional[bool] = None
-
-
-class AlertHistoryOut(BaseModel):
- id: str
- rule_name: str
- fired_at: str
- resolved_at: Optional[str]
- channel: str
- resolved: bool
- duration: str
-
-
-# ─── Alert channels (Phase: real alerting) ────────────────────────────────────
-
-class ChannelIn(BaseModel):
- type: str = Field(..., pattern="^(slack|discord|pagerduty|webhook)$")
- name: str = Field(..., min_length=1, max_length=100)
- webhook_url: Optional[str] = Field(default=None, max_length=1000)
- enabled: bool = True
-
-
-class ChannelUpdate(BaseModel):
- name: Optional[str] = Field(default=None, max_length=100)
- webhook_url: Optional[str] = Field(default=None, max_length=1000)
- enabled: Optional[bool] = None
-
-
-class ChannelOut(BaseModel):
- id: str
- type: str
- name: str
- webhook_url: Optional[str]
- enabled: bool
- last_delivery_at: Optional[str]
- last_delivery_ok: Optional[bool]
- created_at: str
-
-
-class ChannelTestResponse(BaseModel):
- ok: bool
- detail: str
-
-
-# ─── Public status page ───────────────────────────────────────────────────────
-
-class PublicStatusOut(BaseModel):
- project_name: str
- services: list[ServiceStatusOut]
- incidents: list[IncidentOut]
-
-
-class SubscribeRequest(BaseModel):
- email: str = Field(..., min_length=3, max_length=254)
-
- @field_validator("email", mode="after")
- @classmethod
- def _basic_shape(cls, v: str) -> str:
- v = v.strip().lower()
- if "@" not in v or " " in v or v.startswith("@") or v.endswith("@"):
- raise ValueError("not a valid email address")
- return v
-
-
-class SubscribeResponse(BaseModel):
- ok: bool
-
-
-class SubscriptionStatusOut(BaseModel):
- ok: bool
- detail: str
diff --git a/apps/api/core/config.py b/apps/api/core/config.py
index 503e66d..dfb8172 100644
--- a/apps/api/core/config.py
+++ b/apps/api/core/config.py
@@ -29,7 +29,7 @@ class Settings(BaseSettings):
cerebras_model: str = "gpt-oss-120b"
# ── CORS / realtime origins (Phase 8.5) ──────────────────────────────────
- # Comma-separated list of allowed browser origins (dashboard + status page).
+ # Comma-separated list of allowed browser origins.
# Only realtime (Socket.io / SSE) hits the API cross-origin — REST goes
# through the same-origin BFF.
cors_origins: str = "http://localhost:3000"
@@ -38,14 +38,6 @@ class Settings(BaseSettings):
# Max events accepted per project per minute (fixed window). 0 = unlimited.
ingest_rate_limit_per_min: int = 120_000
- # ── Email (public status page subscriptions) ─────────────────────────────
- # Resend API key for confirmation + incident-alert emails. Empty in dev —
- # core.email.send() logs and no-ops instead of raising when unset.
- resend_api_key: SecretStr = SecretStr("")
- email_from: str = "Liveboard Status "
- # Public origin used to build confirm/unsubscribe links in emails.
- public_app_url: str = "http://localhost:3000"
-
# ── Environment ───────────────────────────────────────────────────────────
environment: str = "development"
diff --git a/apps/api/core/email.py b/apps/api/core/email.py
deleted file mode 100644
index 7c78ae6..0000000
--- a/apps/api/core/email.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""
-Status-page subscriber email — confirmation + incident-alert messages, sent
-via the Resend HTTP API (no SDK, same style as core/notify.deliver).
-
-Returns (ok, detail) and never raises — a delivery failure must not crash the
-subscribe endpoint or the anomaly evaluation loop.
-"""
-from __future__ import annotations
-
-import logging
-
-import httpx
-
-from core.config import settings
-
-logger = logging.getLogger(__name__)
-
-_TIMEOUT = 8.0
-_RESEND_URL = "https://api.resend.com/emails"
-
-
-async def send(to: str, subject: str, html: str) -> tuple[bool, str]:
- api_key = settings.resend_api_key.get_secret_value()
- if not api_key:
- logger.warning("RESEND_API_KEY not set — skipping email to %s (%s)", to, subject)
- return False, "email not configured"
-
- try:
- async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
- r = await client.post(
- _RESEND_URL,
- headers={"Authorization": f"Bearer {api_key}"},
- json={
- "from": settings.email_from,
- "to": [to],
- "subject": subject,
- "html": html,
- },
- )
- ok = 200 <= r.status_code < 300
- if not ok:
- logger.warning("Email to %s failed: HTTP %s", to, r.status_code)
- return ok, f"HTTP {r.status_code}"
- except Exception as exc: # network / timeout
- logger.warning("Email to %s errored: %s", to, exc)
- return False, str(exc)[:160]
-
-
-def confirmation_email(confirm_url: str) -> tuple[str, str]:
- subject = "Confirm your Liveboard status alerts"
- html = (
- "Click below to confirm you'd like incident alerts for this status page.
"
- f'Confirm subscription
'
- "If you didn't request this, ignore this email.
"
- )
- return subject, html
-
-
-def incident_email(title: str, summary: str, status_url: str, unsubscribe_url: str) -> tuple[str, str]:
- subject = f"Incident: {title}"
- html = (
- f"{title}
"
- f"{summary}
"
- f'View status page
'
- f'Unsubscribe
'
- )
- return subject, html
diff --git a/apps/api/core/notify.py b/apps/api/core/notify.py
deleted file mode 100644
index e60fbdc..0000000
--- a/apps/api/core/notify.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""
-Alert notification delivery — real HTTP POST to the configured channel.
-
-Slack / Discord / generic webhooks are just JSON POSTs to an incoming-webhook
-URL. PagerDuty uses its Events API v2 (webhook_url holds the routing key).
-Returns (ok, detail) and never raises — a delivery failure must not crash the
-evaluation loop.
-"""
-from __future__ import annotations
-
-import logging
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-_TIMEOUT = 8.0
-
-
-async def deliver(
- channel_type: str,
- webhook_url: str | None,
- *,
- title: str,
- summary: str,
- severity: str,
-) -> tuple[bool, str]:
- if not webhook_url:
- return False, "no webhook url configured"
-
- try:
- async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
- if channel_type == "slack":
- r = await client.post(webhook_url, json={"text": f":rotating_light: *{title}*\n{summary}"})
- elif channel_type == "discord":
- r = await client.post(webhook_url, json={"content": f"🚨 **{title}**\n{summary}"})
- elif channel_type == "pagerduty":
- r = await client.post(
- "https://events.pagerduty.com/v2/enqueue",
- json={
- "routing_key": webhook_url,
- "event_action": "trigger",
- "payload": {
- "summary": f"{title} — {summary}"[:1024],
- "severity": "critical" if severity == "critical" else "warning",
- "source": "liveboard",
- },
- },
- )
- else: # generic webhook
- r = await client.post(
- webhook_url,
- json={"title": title, "summary": summary, "severity": severity, "source": "liveboard"},
- )
-
- ok = 200 <= r.status_code < 300
- if not ok:
- logger.warning("Delivery to %s failed: HTTP %s", channel_type, r.status_code)
- return ok, f"HTTP {r.status_code}"
- except Exception as exc: # network / timeout / bad url
- logger.warning("Delivery to %s errored: %s", channel_type, exc)
- return False, str(exc)[:160]
diff --git a/apps/api/main.py b/apps/api/main.py
index e87f954..ac497b8 100644
--- a/apps/api/main.py
+++ b/apps/api/main.py
@@ -8,7 +8,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from api.routes import alerts, ingest, internal, projects, public_status, query, spans
+from api.routes import ingest, internal, projects, query
from core.config import settings
from core.database import close_pool, get_pool
from core.redis_client import close_redis
@@ -68,9 +68,6 @@ async def lifespan(app: FastAPI):
_fastapi.include_router(projects.router)
_fastapi.include_router(internal.router)
_fastapi.include_router(query.router)
-_fastapi.include_router(alerts.router)
-_fastapi.include_router(spans.router)
-_fastapi.include_router(public_status.router)
_fastapi.include_router(sse_router)
diff --git a/apps/api/migrations/versions/001_initial.py b/apps/api/migrations/versions/001_initial.py
index 47bade0..ca1a75a 100644
--- a/apps/api/migrations/versions/001_initial.py
+++ b/apps/api/migrations/versions/001_initial.py
@@ -1,4 +1,4 @@
-"""Initial schema — users, projects, events hypertable, spans, incidents
+"""Initial schema — users, projects, events hypertable, incidents
Revision ID: 001
Revises:
@@ -63,24 +63,6 @@ def upgrade() -> None:
op.execute("CREATE INDEX IF NOT EXISTS idx_events_project_time ON events(project_id, time DESC);")
op.execute("CREATE INDEX IF NOT EXISTS idx_events_route ON events(project_id, route, time DESC);")
- # ── Spans (distributed tracing — populated from Phase 6) ─────────────────
- op.execute("""
- CREATE TABLE IF NOT EXISTS spans (
- span_id UUID PRIMARY KEY,
- trace_id UUID NOT NULL,
- parent_id UUID,
- project_id UUID NOT NULL,
- service_name TEXT,
- operation TEXT,
- start_time TIMESTAMPTZ,
- duration_ms INTEGER,
- status_code SMALLINT,
- tags JSONB
- );
- """)
- op.execute("CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id);")
- op.execute("CREATE INDEX IF NOT EXISTS idx_spans_project ON spans(project_id, start_time DESC);")
-
# ── 1-minute continuous aggregate ────────────────────────────────────────
op.execute("""
CREATE MATERIALIZED VIEW IF NOT EXISTS events_1min
@@ -129,7 +111,6 @@ def upgrade() -> None:
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS incidents;")
op.execute("DROP MATERIALIZED VIEW IF EXISTS events_1min;")
- op.execute("DROP TABLE IF EXISTS spans;")
op.execute("DROP TABLE IF EXISTS events;")
op.execute("DROP TABLE IF EXISTS projects;")
op.execute("DROP TABLE IF EXISTS users;")
diff --git a/apps/api/migrations/versions/002_alert_rules.py b/apps/api/migrations/versions/002_alert_rules.py
deleted file mode 100644
index 82d9ba2..0000000
--- a/apps/api/migrations/versions/002_alert_rules.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Add alert_rules and alert_history tables
-
-Revision ID: 002
-Revises: 001
-Create Date: 2026-06-13
-"""
-
-from alembic import op
-
-revision = "002"
-down_revision = "001"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
- op.execute("""
- CREATE TABLE IF NOT EXISTS alert_rules (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- name TEXT NOT NULL,
- metric TEXT NOT NULL,
- operator TEXT NOT NULL CHECK (operator IN ('>', '<', '=', '!=')),
- threshold FLOAT NOT NULL,
- window_min INTEGER NOT NULL DEFAULT 5,
- severity TEXT NOT NULL DEFAULT 'warning'
- CHECK (severity IN ('critical', 'warning', 'info')),
- channel TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'ok'
- CHECK (status IN ('firing', 'ok', 'pending')),
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- last_triggered TIMESTAMPTZ,
- created_at TIMESTAMPTZ DEFAULT now(),
- updated_at TIMESTAMPTZ DEFAULT now()
- );
- """)
- op.execute("CREATE INDEX IF NOT EXISTS idx_alert_rules_project ON alert_rules(project_id);")
-
- op.execute("""
- CREATE TABLE IF NOT EXISTS alert_history (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- project_id UUID NOT NULL,
- rule_id UUID REFERENCES alert_rules(id) ON DELETE SET NULL,
- rule_name TEXT NOT NULL,
- fired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- resolved_at TIMESTAMPTZ,
- channel TEXT NOT NULL DEFAULT ''
- );
- """)
- op.execute(
- "CREATE INDEX IF NOT EXISTS idx_alert_history_project "
- "ON alert_history(project_id, fired_at DESC);"
- )
-
-
-def downgrade() -> None:
- op.execute("DROP TABLE IF EXISTS alert_history;")
- op.execute("DROP TABLE IF EXISTS alert_rules;")
diff --git a/apps/api/migrations/versions/003_multitenancy.py b/apps/api/migrations/versions/002_multitenancy.py
similarity index 96%
rename from apps/api/migrations/versions/003_multitenancy.py
rename to apps/api/migrations/versions/002_multitenancy.py
index 601a2f2..e1e5ed1 100644
--- a/apps/api/migrations/versions/003_multitenancy.py
+++ b/apps/api/migrations/versions/002_multitenancy.py
@@ -1,7 +1,7 @@
"""Multi-tenancy: organizations, memberships, projects.org_id
-Revision ID: 003
-Revises: 002
+Revision ID: 002
+Revises: 001
Create Date: 2026-07-18
Introduces the tenancy identity layer:
@@ -10,15 +10,15 @@
projects.org_id — every project now belongs to exactly one org
Telemetry tables already carry project_id, so an org owns its data
-transitively (org -> projects -> events/spans/incidents). This migration
+transitively (org -> projects -> events/incidents). This migration
only adds the *ownership* layer; read-time authorization is enforced in
the app (Phase 8.2) and, later, Postgres RLS (Phase 8.4).
"""
from alembic import op
-revision = "003"
-down_revision = "002"
+revision = "002"
+down_revision = "001"
branch_labels = None
depends_on = None
diff --git a/apps/api/migrations/versions/004_api_keys.py b/apps/api/migrations/versions/003_api_keys.py
similarity index 96%
rename from apps/api/migrations/versions/004_api_keys.py
rename to apps/api/migrations/versions/003_api_keys.py
index 6defb75..c211cf8 100644
--- a/apps/api/migrations/versions/004_api_keys.py
+++ b/apps/api/migrations/versions/003_api_keys.py
@@ -1,7 +1,7 @@
"""API keys table — multiple keys per project, rotation + revocation
-Revision ID: 004
-Revises: 003
+Revision ID: 003
+Revises: 002
Create Date: 2026-07-18
Moves API-key auth from the single `projects.api_key` column to a dedicated
@@ -11,8 +11,8 @@
from alembic import op
-revision = "004"
-down_revision = "003"
+revision = "003"
+down_revision = "002"
branch_labels = None
depends_on = None
diff --git a/apps/api/migrations/versions/005_rls.py b/apps/api/migrations/versions/004_rls.py
similarity index 88%
rename from apps/api/migrations/versions/005_rls.py
rename to apps/api/migrations/versions/004_rls.py
index b153919..bad79a8 100644
--- a/apps/api/migrations/versions/005_rls.py
+++ b/apps/api/migrations/versions/004_rls.py
@@ -1,7 +1,7 @@
"""Row-Level Security backstop for tenant isolation
-Revision ID: 005
-Revises: 004
+Revision ID: 004
+Revises: 003
Create Date: 2026-07-18
Defense-in-depth: even if an app query forgets its `WHERE project_id = …`,
@@ -12,7 +12,7 @@
• Dashboard READ connections `SET ROLE dashboard_reader` + set the
`app.project_id` GUC (see api.deps.scoped_conn). Non-owner ⇒ RLS is
enforced ⇒ only rows for the active project are visible.
- • The owner (ingest worker, alert writes) is NOT forced under RLS, so
+ • The owner (ingest worker, incident writes) is NOT forced under RLS, so
cross-project writes/aggregation keep working.
A read that forgets to set `app.project_id` sees ZERO rows (fail closed).
@@ -20,12 +20,12 @@
from alembic import op
-revision = "005"
-down_revision = "004"
+revision = "004"
+down_revision = "003"
branch_labels = None
depends_on = None
-_TABLES = ["events", "spans", "incidents", "alert_rules", "alert_history"]
+_TABLES = ["events", "incidents"]
def upgrade() -> None:
@@ -46,7 +46,7 @@ def upgrade() -> None:
op.execute(f"GRANT SELECT ON {t} TO dashboard_reader;")
op.execute(f"ALTER TABLE {t} ENABLE ROW LEVEL SECURITY;")
# Scoped SELECT policy — applies only to dashboard_reader; the owner
- # (worker/ingest/alert writes) is unaffected.
+ # (worker/ingest/incident writes) is unaffected.
op.execute(f"""
CREATE POLICY {t}_tenant_isolation ON {t}
FOR SELECT
diff --git a/apps/api/migrations/versions/006_alert_channels.py b/apps/api/migrations/versions/006_alert_channels.py
deleted file mode 100644
index 9534c41..0000000
--- a/apps/api/migrations/versions/006_alert_channels.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Alert delivery channels (real Slack/Discord/PagerDuty/webhook targets)
-
-Revision ID: 006
-Revises: 005
-Create Date: 2026-07-19
-
-Backs the alerts "Channels" tab with real, per-project destinations. Alert
-rules reference a channel by name (alert_rules.channel); the evaluation worker
-looks the channel up here to get its webhook URL and delivers the notification.
-"""
-
-from alembic import op
-
-revision = "006"
-down_revision = "005"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
- op.execute("""
- CREATE TABLE IF NOT EXISTS alert_channels (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- type TEXT NOT NULL CHECK (type IN ('slack', 'discord', 'pagerduty', 'webhook')),
- name TEXT NOT NULL,
- webhook_url TEXT,
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- last_delivery_at TIMESTAMPTZ,
- last_delivery_ok BOOLEAN,
- created_at TIMESTAMPTZ DEFAULT now()
- );
- """)
- op.execute("CREATE INDEX IF NOT EXISTS idx_alert_channels_project ON alert_channels(project_id);")
-
- # RLS backstop — dashboard reads (dashboard_reader role) are scoped to the
- # active project via app.project_id, same as the other tenant tables.
- op.execute("GRANT SELECT ON alert_channels TO dashboard_reader;")
- op.execute("ALTER TABLE alert_channels ENABLE ROW LEVEL SECURITY;")
- op.execute("""
- CREATE POLICY alert_channels_tenant_isolation ON alert_channels
- FOR SELECT
- TO dashboard_reader
- USING (project_id = NULLIF(current_setting('app.project_id', true), '')::uuid);
- """)
-
-
-def downgrade() -> None:
- op.execute("DROP POLICY IF EXISTS alert_channels_tenant_isolation ON alert_channels;")
- op.execute("DROP TABLE IF EXISTS alert_channels;")
diff --git a/apps/api/migrations/versions/007_public_status_page.py b/apps/api/migrations/versions/007_public_status_page.py
deleted file mode 100644
index 3204d55..0000000
--- a/apps/api/migrations/versions/007_public_status_page.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""Public per-project status page (opt-in)
-
-Revision ID: 007
-Revises: 006
-Create Date: 2026-07-20
-
-Lets a project owner publish a logged-out-visitable status page at
-/status/{public_slug}, scoped to exactly that project. Off by default —
-`status_page_enabled` must be flipped on (see /v1/internal/projects/{id}/status-page)
-before the public route will serve anything for a project.
-"""
-
-from alembic import op
-
-revision = "007"
-down_revision = "006"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
- op.execute("ALTER TABLE projects ADD COLUMN IF NOT EXISTS public_slug TEXT;")
- op.execute(
- "ALTER TABLE projects ADD COLUMN IF NOT EXISTS status_page_enabled "
- "BOOLEAN NOT NULL DEFAULT FALSE;"
- )
- op.execute(
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_public_slug "
- "ON projects(public_slug) WHERE public_slug IS NOT NULL;"
- )
-
-
-def downgrade() -> None:
- op.execute("DROP INDEX IF EXISTS idx_projects_public_slug;")
- op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS status_page_enabled;")
- op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS public_slug;")
diff --git a/apps/api/migrations/versions/008_status_subscribers.py b/apps/api/migrations/versions/008_status_subscribers.py
deleted file mode 100644
index 1f2a092..0000000
--- a/apps/api/migrations/versions/008_status_subscribers.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Status page incident-alert subscribers (double opt-in by email)
-
-Revision ID: 008
-Revises: 007
-Create Date: 2026-07-20
-
-Backs the public status page's "Get Incident Alerts" form. A row is created
-on subscribe with `confirmed_at = NULL`; the anomaly worker only emails rows
-where `confirmed_at IS NOT NULL AND unsubscribed_at IS NULL`, so an address is
-never emailed until it's clicked its confirmation link.
-"""
-
-from alembic import op
-
-revision = "008"
-down_revision = "007"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
- op.execute("""
- CREATE TABLE IF NOT EXISTS status_subscribers (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- email TEXT NOT NULL,
- confirm_token TEXT UNIQUE NOT NULL,
- confirmed_at TIMESTAMPTZ,
- unsubscribed_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ DEFAULT now(),
- UNIQUE (project_id, email)
- );
- """)
- op.execute(
- "CREATE INDEX IF NOT EXISTS idx_status_subscribers_active "
- "ON status_subscribers(project_id) "
- "WHERE confirmed_at IS NOT NULL AND unsubscribed_at IS NULL;"
- )
-
-
-def downgrade() -> None:
- op.execute("DROP TABLE IF EXISTS status_subscribers;")
diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt
index eea978a..360640a 100644
--- a/apps/api/requirements.txt
+++ b/apps/api/requirements.txt
@@ -18,9 +18,5 @@ python-socketio[asyncio_client]==5.11.4
# AI (Phase 5)
cerebras-cloud-sdk==1.19.0
-# Auth
-python-jose[cryptography]==3.3.0
-passlib[bcrypt]==1.7.4
-
-# HTTP client (smoke test + SDK calls)
+# HTTP client (smoke test)
httpx==0.27.2
diff --git a/apps/api/worker/alerts.py b/apps/api/worker/alerts.py
deleted file mode 100644
index a685361..0000000
--- a/apps/api/worker/alerts.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""
-Alert-rule evaluation worker.
-
-Every 30 s, for each ENABLED rule:
- 1. Compute its metric over the rule's window from the events table.
- 2. Compare against the threshold using the rule's operator.
- 3. State machine:
- - breach + not already firing -> status='firing', last_triggered=now,
- insert alert_history row, deliver to the rule's channel
- - no breach + currently firing -> status='ok', resolve the open history row
- Owner DB connection (bypasses RLS); the worker sees every project's rules.
-"""
-from __future__ import annotations
-
-import asyncio
-import logging
-
-from core.database import get_pool
-from core.notify import deliver
-
-logger = logging.getLogger(__name__)
-
-_INTERVAL = 30 # seconds
-
-_METRIC = {
- "error_rate": ("Error rate", "%"),
- "p99_latency": ("p99 latency", "ms"),
- "requests_per_min": ("Requests/min", ""),
-}
-_OP_WORD = {">": "exceeds", "<": "drops below", "=": "equals", "!=": "differs from"}
-
-
-def _compare(value: float, operator: str, threshold: float) -> bool:
- if operator == ">":
- return value > threshold
- if operator == "<":
- return value < threshold
- if operator == "=":
- return abs(value - threshold) < 1e-6
- if operator == "!=":
- return abs(value - threshold) >= 1e-6
- return False
-
-
-async def _metric_value(conn, project_id, metric: str, window_min: int) -> float | None:
- """Return the current metric value over the window, or None if not evaluable."""
- row = await conn.fetchrow(
- """
- SELECT
- COUNT(*)::int AS total,
- COALESCE(100.0 * SUM((status_code >= 400)::int) / NULLIF(COUNT(*), 0), 0)::float AS error_rate,
- COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms), 0)::float AS p99
- FROM events
- WHERE project_id = $1 AND time > now() - ($2 * interval '1 minute')
- """,
- project_id,
- window_min,
- )
- total = row["total"] or 0
- if metric == "requests_per_min":
- return total / max(window_min, 1)
- if total == 0:
- return None # can't judge error rate / latency with no traffic
- if metric == "error_rate":
- return float(row["error_rate"])
- if metric == "p99_latency":
- return float(row["p99"])
- return None # unsupported metric (e.g. apdex) — skip
-
-
-async def _deliver_for_rule(conn, project_id, rule, title: str, summary: str) -> None:
- ch = await conn.fetchrow(
- """
- SELECT id, type, webhook_url FROM alert_channels
- WHERE project_id = $1 AND name = $2 AND enabled = TRUE
- LIMIT 1
- """,
- project_id,
- rule["channel"],
- )
- if ch is None:
- return
- ok, _detail = await deliver(ch["type"], ch["webhook_url"], title=title, summary=summary, severity=rule["severity"])
- await conn.execute(
- "UPDATE alert_channels SET last_delivery_at = now(), last_delivery_ok = $2 WHERE id = $1",
- ch["id"],
- ok,
- )
-
-
-async def _evaluate_all() -> None:
- pool = await get_pool()
- async with pool.acquire() as conn:
- rules = await conn.fetch(
- """
- SELECT id, project_id, name, metric, operator, threshold, window_min,
- severity, channel, status
- FROM alert_rules
- WHERE enabled = TRUE
- """
- )
- for rule in rules:
- value = await _metric_value(conn, rule["project_id"], rule["metric"], rule["window_min"])
- if value is None:
- continue
-
- breach = _compare(value, rule["operator"], rule["threshold"])
- label, unit = _METRIC.get(rule["metric"], (rule["metric"], ""))
- op_word = _OP_WORD.get(rule["operator"], rule["operator"])
-
- if breach and rule["status"] != "firing":
- title = f"{label} {op_word} {rule['threshold']:g}{unit}"
- summary = (
- f"{label} hit {value:.1f}{unit} over the last {rule['window_min']}m "
- f"(threshold {rule['operator']} {rule['threshold']:g}{unit})."
- )
- await conn.execute(
- "UPDATE alert_rules SET status='firing', last_triggered=now(), updated_at=now() WHERE id=$1",
- rule["id"],
- )
- await conn.execute(
- """
- INSERT INTO alert_history (project_id, rule_id, rule_name, fired_at, channel)
- VALUES ($1, $2, $3, now(), $4)
- """,
- rule["project_id"], rule["id"], rule["name"], rule["channel"],
- )
- await _deliver_for_rule(conn, rule["project_id"], rule, title, summary)
- logger.info("Alert FIRING rule=%s project=%s value=%.1f", rule["name"], rule["project_id"], value)
-
- elif not breach and rule["status"] == "firing":
- await conn.execute(
- "UPDATE alert_rules SET status='ok', updated_at=now() WHERE id=$1",
- rule["id"],
- )
- await conn.execute(
- "UPDATE alert_history SET resolved_at=now() WHERE rule_id=$1 AND resolved_at IS NULL",
- rule["id"],
- )
- logger.info("Alert RESOLVED rule=%s project=%s", rule["name"], rule["project_id"])
-
-
-async def run() -> None:
- logger.info("Alert evaluator started — checking every %d s", _INTERVAL)
- while True:
- try:
- await _evaluate_all()
- except Exception:
- logger.exception("Alert evaluation cycle failed")
- await asyncio.sleep(_INTERVAL)
diff --git a/apps/api/worker/anomaly.py b/apps/api/worker/anomaly.py
index dda0b85..e8ff03c 100644
--- a/apps/api/worker/anomaly.py
+++ b/apps/api/worker/anomaly.py
@@ -21,7 +21,6 @@
from core.config import settings
from core.database import get_pool
-from core.email import incident_email, send as send_email
from core.redis_client import get_redis
logger = logging.getLogger(__name__)
@@ -241,37 +240,6 @@ async def _handle_anomaly(
await redis.publish(f"incidents:{project_id}", json.dumps(payload))
logger.info("Incident created id=%s project=%s", row["id"], project_id)
- await _notify_subscribers(conn, project_id, title, summary)
-
-
-async def _notify_subscribers(conn, project_id: str, title: str, summary: str) -> None:
- """
- Email confirmed, active status-page subscribers about a new incident.
- Best-effort — a delivery failure must not break the anomaly loop, same as
- the alert-channel worker's notify.deliver.
- """
- rows = await conn.fetch(
- """
- SELECT s.email, s.confirm_token, p.public_slug
- FROM status_subscribers s
- JOIN projects p ON p.id = s.project_id
- WHERE s.project_id = $1 AND s.confirmed_at IS NOT NULL AND s.unsubscribed_at IS NULL
- """,
- project_id,
- )
- if not rows or not rows[0]["public_slug"]:
- return
-
- status_url = f"{settings.public_app_url}/status/{rows[0]['public_slug']}"
- for row in rows:
- unsubscribe_url = f"{settings.public_app_url}/status/unsubscribe/{row['confirm_token']}"
- subject, html = incident_email(title, summary, status_url, unsubscribe_url)
- ok, detail = await send_email(row["email"], subject, html)
- if not ok:
- logger.warning("Incident email to %s failed: %s", row["email"], detail)
-
-
-# ─── Cerebras summariser ─────────────────────────────────────────────────────
async def _generate_summary(
metric: str,
diff --git a/apps/api/worker/main.py b/apps/api/worker/main.py
index 19ed504..164571b 100644
--- a/apps/api/worker/main.py
+++ b/apps/api/worker/main.py
@@ -4,9 +4,10 @@
Run as: python -m worker.main
Docker: CMD ["python", "-m", "worker.main"]
-Two concurrent tasks:
+Three concurrent tasks:
aggregator — XREADGROUP → TimescaleDB COPY (at-least-once delivery)
metrics — query last-minute stats per project → Redis pub/sub every 1 s
+ anomaly — rolling z-score check every 60 s → AI incident summary
"""
import asyncio
@@ -14,7 +15,6 @@
import signal
from worker.aggregator import run as run_aggregator
-from worker.alerts import run as run_alerts
from worker.anomaly import run as run_anomaly
from worker.metrics import run as run_metrics
@@ -30,20 +30,18 @@ async def main() -> None:
agg_task = asyncio.create_task(run_aggregator(), name="aggregator")
metrics_task = asyncio.create_task(run_metrics(), name="metrics-publisher")
anomaly_task = asyncio.create_task(run_anomaly(), name="anomaly-detector")
- alerts_task = asyncio.create_task(run_alerts(), name="alert-evaluator")
def _shutdown(sig: signal.Signals) -> None:
logger.info("Received %s, stopping worker", sig.name)
agg_task.cancel()
metrics_task.cancel()
anomaly_task.cancel()
- alerts_task.cancel()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _shutdown, sig)
try:
- await asyncio.gather(agg_task, metrics_task, anomaly_task, alerts_task, return_exceptions=True)
+ await asyncio.gather(agg_task, metrics_task, anomaly_task, return_exceptions=True)
except asyncio.CancelledError:
pass
diff --git a/components/alerts/rule-list.tsx b/components/alerts/rule-list.tsx
deleted file mode 100644
index 2d52696..0000000
--- a/components/alerts/rule-list.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { Zap, Bell, Info } from "lucide-react";
-import { Badge } from "@/components/ui/badge";
-import { timeAgo, cn } from "@/lib/utils";
-import type { AlertRule } from "@/lib/types";
-
-interface RuleListProps {
- rules: AlertRule[];
- onToggle?: (id: string, enabled: boolean) => void;
-}
-
-const METRIC_LABELS: Record = {
- error_rate: "Error Rate",
- p99_latency: "p99 Latency",
- requests_per_min: "Requests/min",
- auth_4xx_rate: "Auth 4xx Rate",
- payment_error_rate: "Payment Errors",
-};
-
-const OPERATOR_LABELS: Record = {
- ">": "exceeds",
- "<": "drops below",
- "=": "equals",
- "!=": "differs from",
-};
-
-const SEVERITY_ICONS = {
- critical: Zap,
- warning: Bell,
- info: Info,
-};
-
-export function RuleList({ rules, onToggle }: RuleListProps) {
- // Optimistic copy for instant toggle feedback — re-synced whenever the real
- // rules prop changes (e.g. project switch, or empty when there are none).
- const [localRules, setLocalRules] = useState(rules);
- useEffect(() => { setLocalRules(rules); }, [rules]);
-
- const toggle = (id: string) => {
- const rule = localRules.find((r) => r.id === id);
- if (!rule) return;
- const newEnabled = !rule.enabled;
- setLocalRules((prev) => prev.map((r) => (r.id === id ? { ...r, enabled: newEnabled } : r)));
- onToggle?.(id, newEnabled);
- };
-
- return (
-
-
- {["Rule", "Condition", "Channel", "Last Triggered", "Status", ""].map((h) => (
-
- {h}
-
- ))}
-
-
-
- {localRules.map((rule) => {
- const Icon = SEVERITY_ICONS[rule.severity];
- const thresholdUnit = rule.metric.includes("latency") ? "ms" : rule.metric.includes("rate") ? "%" : "";
-
- return (
-
-
-
-
- {METRIC_LABELS[rule.metric]} {OPERATOR_LABELS[rule.operator]} {rule.threshold}{thresholdUnit} / {rule.window}min
-
-
-
{rule.channel}
-
-
- {rule.lastTriggered ? timeAgo(rule.lastTriggered) : "—"}
-
-
-
- {rule.status === "firing" ? (
- Firing
- ) : rule.status === "pending" ? (
- Pending
- ) : (
- OK
- )}
-
-
-
- toggle(rule.id)}
- className={cn(
- "h-4 w-7 rounded-full transition-colors relative flex items-center",
- rule.enabled ? "bg-blue" : "bg-[#2A2A2A]"
- )}
- >
-
-
-
-
- );
- })}
- {localRules.length === 0 && (
-
- No alert rules yet — create one below.
-
- )}
-
-
- );
-}
diff --git a/components/charts/latency-histogram.tsx b/components/charts/latency-histogram.tsx
deleted file mode 100644
index a710400..0000000
--- a/components/charts/latency-histogram.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client";
-
-import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell } from "recharts";
-
-interface LatencyHistogramProps {
- p50: number;
- p95: number;
- p99: number;
-}
-
-interface TooltipPayload {
- value: number;
- payload: { label: string };
-}
-
-interface CustomTooltipProps {
- active?: boolean;
- payload?: TooltipPayload[];
-}
-
-function CustomTooltip({ active, payload }: CustomTooltipProps) {
- if (!active || !payload?.length) return null;
- return (
-
-
{payload[0].payload.label}
-
{payload[0].value}ms
-
- );
-}
-
-export function LatencyHistogram({ p50, p95, p99 }: LatencyHistogramProps) {
- const data = [
- { label: "p50", value: p50, color: "#22C55E" },
- { label: "p95", value: p95, color: "#F59E0B" },
- { label: "p99", value: p99, color: "#EF4444" },
- ];
-
- return (
-
-
-
-
- } cursor={{ fill: "rgba(255,255,255,0.03)" }} />
-
- {data.map((entry, index) => (
- |
- ))}
-
-
-
- );
-}
diff --git a/components/endpoints/endpoint-comparison.tsx b/components/endpoints/endpoint-comparison.tsx
deleted file mode 100644
index 823c882..0000000
--- a/components/endpoints/endpoint-comparison.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { X } from "lucide-react";
-import {
- LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
-} from "recharts";
-import { formatMs, getMethodColor } from "@/lib/utils";
-import type { Endpoint } from "@/lib/types";
-
-interface EndpointComparisonProps {
- endpoints: [Endpoint, Endpoint];
- onClose: () => void;
-}
-
-function generateLatencyHistory(p99: number) {
- return Array.from({ length: 24 }, (_, i) => ({
- hour: `${i}:00`,
- value: Math.max(10, p99 + Math.round((Math.random() - 0.5) * p99 * 0.4)),
- }));
-}
-
-const COLORS = ["#378ADD", "#A855F7"] as const;
-
-export function EndpointComparison({ endpoints, onClose }: EndpointComparisonProps) {
- const [a, b] = endpoints;
-
- const chartData = useMemo(() => {
- const aHistory = generateLatencyHistory(a.p99);
- const bHistory = generateLatencyHistory(b.p99);
- return aHistory.map((pt, i) => ({
- hour: pt.hour,
- [a.route]: pt.value,
- [b.route]: bHistory[i]!.value,
- }));
- }, [a, b]);
-
- const metrics: { label: string; aVal: string; bVal: string; winner: "a" | "b" | "tie" }[] = [
- {
- label: "p50",
- aVal: formatMs(a.p50),
- bVal: formatMs(b.p50),
- winner: a.p50 < b.p50 ? "a" : b.p50 < a.p50 ? "b" : "tie",
- },
- {
- label: "p99",
- aVal: formatMs(a.p99),
- bVal: formatMs(b.p99),
- winner: a.p99 < b.p99 ? "a" : b.p99 < a.p99 ? "b" : "tie",
- },
- {
- label: "Error Rate",
- aVal: `${a.errorRate.toFixed(1)}%`,
- bVal: `${b.errorRate.toFixed(1)}%`,
- winner: a.errorRate < b.errorRate ? "a" : b.errorRate < a.errorRate ? "b" : "tie",
- },
- {
- label: "Health",
- aVal: String(a.healthScore),
- bVal: String(b.healthScore),
- winner: a.healthScore > b.healthScore ? "a" : b.healthScore > a.healthScore ? "b" : "tie",
- },
- ];
-
- return (
-
- {/* Header */}
-
-
- {endpoints.map((ep, i) => (
-
-
-
- {ep.method}
-
-
{ep.route}
-
- ))}
-
-
-
-
-
-
- {/* Metric comparison grid */}
-
- {metrics.map((m) => (
-
-
{m.label}
-
-
- A
-
- {m.aVal}
-
-
-
- B
-
- {m.bVal}
-
-
-
-
- ))}
-
-
- {/* p99 Latency chart */}
-
-
p99 Latency — 24h
-
-
-
-
- `${v}ms`} width={45} />
- `${String(v)}ms`}
- />
-
-
-
-
-
-
-
-
- );
-}
diff --git a/components/endpoints/endpoint-drawer.tsx b/components/endpoints/endpoint-drawer.tsx
deleted file mode 100644
index d303293..0000000
--- a/components/endpoints/endpoint-drawer.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-"use client";
-
-import { motion, AnimatePresence } from "framer-motion";
-import { X } from "lucide-react";
-import { LatencyHistogram } from "@/components/charts/latency-histogram";
-import { getMethodColor, getHealthColor, getErrorRateColor, formatMs, formatNumber } from "@/lib/utils";
-import type { Endpoint } from "@/lib/types";
-
-interface EndpointDrawerProps {
- endpoint: Endpoint | null;
- onClose: () => void;
-}
-
-export function EndpointDrawer({ endpoint, onClose }: EndpointDrawerProps) {
- return (
-
- {endpoint && (
-
- {/* Header */}
-
-
-
-
- {endpoint.method}
-
-
-
-
{endpoint.route}
-
-
-
-
-
-
-
- {/* Health Score */}
-
-
- Health Score
-
- {endpoint.healthScore}
-
-
-
-
-
- {/* Stats grid */}
-
- {[
- { label: "Requests 24h", value: formatNumber(endpoint.requests24h) },
- {
- label: "Error Rate",
- value: `${endpoint.errorRate.toFixed(1)}%`,
- color: getErrorRateColor(endpoint.errorRate),
- },
- ].map((stat) => (
-
-
{stat.label}
-
- {stat.value}
-
-
- ))}
-
-
- {/* Latency */}
-
-
Latency Distribution
-
-
- {[
- { label: "p50", value: formatMs(endpoint.p50), color: "#22C55E" },
- { label: "p95", value: formatMs(endpoint.p95), color: "#F59E0B" },
- { label: "p99", value: formatMs(endpoint.p99), color: "#EF4444" },
- ].map((p) => (
-
-
{p.label}
-
- {p.value}
-
-
- ))}
-
-
-
- {/* Top errors */}
-
-
Top Errors
- {endpoint.errorRate > 0 ? (
-
- {[
- { code: 422, msg: "Validation failed: required field missing", count: Math.floor(endpoint.requests24h * 0.008) },
- { code: 500, msg: "Internal server error: database timeout", count: Math.floor(endpoint.requests24h * 0.004) },
- ].map((err, i) => (
-
- {err.code}
- {err.msg}
- {err.count}x
-
- ))}
-
- ) : (
-
No errors in selected window
- )}
-
-
-
- )}
-
- );
-}
diff --git a/components/endpoints/endpoint-table.tsx b/components/endpoints/endpoint-table.tsx
index b37551e..b6f5787 100644
--- a/components/endpoints/endpoint-table.tsx
+++ b/components/endpoints/endpoint-table.tsx
@@ -1,21 +1,18 @@
"use client";
import { useState } from "react";
-import { ArrowUpDown, ChevronRight } from "lucide-react";
+import { ArrowUpDown } from "lucide-react";
import { getMethodColor, getErrorRateColor, getHealthColor, formatMs, formatNumber } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { Endpoint } from "@/lib/types";
interface EndpointTableProps {
endpoints: Endpoint[];
- onSelect: (endpoint: Endpoint) => void;
- selected?: string;
- compareSelected?: string[];
}
type SortKey = keyof Pick;
-export function EndpointTable({ endpoints, onSelect, selected, compareSelected = [] }: EndpointTableProps) {
+export function EndpointTable({ endpoints }: EndpointTableProps) {
const [sortKey, setSortKey] = useState("requests24h");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -41,7 +38,7 @@ export function EndpointTable({ endpoints, onSelect, selected, compareSelected =
return (
{/* Header */}
-
+
Endpoint
@@ -49,20 +46,14 @@ export function EndpointTable({ endpoints, onSelect, selected, compareSelected =
Health
-
{/* Rows */}
{sorted.map((ep) => (
-
onSelect(ep)}
- className={cn(
- "w-full grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_80px_36px] gap-2 px-4 py-2.5 items-center text-left hover:bg-[#151515] transition-colors",
- selected === ep.id && "bg-blue-dim border-l-2 border-l-blue",
- compareSelected.includes(ep.id) && "bg-[#0D1520] border-l-2 border-l-[#A855F7]"
- )}
+ className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_80px] gap-2 px-4 py-2.5 items-center text-left hover:bg-[#151515] transition-colors"
>
-
-
+
))}
{sorted.length === 0 && (
diff --git a/components/landing/channel-marks.tsx b/components/landing/channel-marks.tsx
deleted file mode 100644
index d39004d..0000000
--- a/components/landing/channel-marks.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Hash, Webhook } from "lucide-react";
-
-interface ChannelMark {
- key: string;
- label: string;
- slug?: string;
-}
-
-// Discord and PagerDuty are real brand marks (Simple Icons CDN). Slack's mark
-// was removed from Simple Icons, so it falls back to a generic glyph like
-// "Webhook" — a delivery method, not a brand.
-export const ALERT_CHANNELS: ChannelMark[] = [
- { key: "slack", label: "Slack" },
- { key: "discord", label: "Discord", slug: "discord" },
- { key: "pagerduty", label: "PagerDuty", slug: "pagerduty" },
- { key: "webhook", label: "Webhook" },
-];
-
-export function ChannelMarkIcon({ channel, className }: { channel: ChannelMark; className?: string }) {
- if (!channel.slug) {
- const Icon = channel.key === "slack" ? Hash : Webhook;
- return
;
- }
- return (
- // eslint-disable-next-line @next/next/no-img-element -- external brand mark, next/image needs SVG allow-listing for no benefit here
-
- );
-}
diff --git a/components/landing/feature-grid.tsx b/components/landing/feature-grid.tsx
index 65cc6aa..cc7a854 100644
--- a/components/landing/feature-grid.tsx
+++ b/components/landing/feature-grid.tsx
@@ -1,6 +1,5 @@
import { RevealOnScroll } from "@/components/landing/reveal-on-scroll";
import { ScreenshotFrame } from "@/components/landing/screenshot-frame";
-import { ALERT_CHANNELS, ChannelMarkIcon } from "@/components/landing/channel-marks";
import { InstallSnippet } from "@/components/landing/install-snippet";
export function FeatureGrid() {
@@ -11,7 +10,7 @@ export function FeatureGrid() {
Every request, instrumented.
- Distributed traces, per-endpoint health scores, and alerts routed to the tools your team already uses.
+ Live request metrics, per-endpoint health scores, and AI incident summaries — from one line of middleware.
@@ -22,14 +21,14 @@ export function FeatureGrid() {
-
Distributed tracing
+
Live request stream
- Flame graphs and service maps for every request, down to the individual span. Trace IDs propagate across all five SDK adapters automatically.
+ Request volume, response codes, and a tailing log of every call — pushed to the browser over Socket.io and SSE as the traffic happens, not on a refresh timer.
@@ -52,19 +51,24 @@ export function FeatureGrid() {
-
Alerts that reach your team
+
Anomalies, not thresholds
- Threshold rules on error rate or latency, routed to the channel your on-call rotation actually watches.
+ A rolling z-score over the last 24 hours of error rate and p99 latency decides what is actually unusual for your API — no thresholds to tune, no alert fatigue.
-
- {ALERT_CHANNELS.map((channel) => (
-
-
-
{channel.label}
+
+ {[
+ { term: "Window", detail: "24 h rolling" },
+ { term: "Trigger", detail: "|z| > 3" },
+ { term: "Rate limit", detail: "10 / project / h" },
+ { term: "Summary", detail: "llama-3.3-70b" },
+ ].map((stat) => (
+
+
{stat.term}
+ {stat.detail}
))}
-
+
diff --git a/components/landing/footer.tsx b/components/landing/footer.tsx
index 63e1c5a..c2e8c3a 100644
--- a/components/landing/footer.tsx
+++ b/components/landing/footer.tsx
@@ -13,7 +13,7 @@ const COLUMNS: { title: string; links: FooterLink[] }[] = [
links: [
{ label: "Features", href: "#features" },
{ label: "Anomaly detection", href: "#detection" },
- { label: "Status page", href: "#status" },
+ { label: "Self-hosting", href: "https://github.com/ryzrr/liveboard#quick-start", external: true },
],
},
{
@@ -37,7 +37,7 @@ export function Footer() {
Liveboard
- Open source API observability. Traces, alerts, and AI incident summaries from one line of middleware.
+ Open source API observability. Live metrics, error tracking, and AI incident summaries from one line of middleware.
diff --git a/components/landing/hero.tsx b/components/landing/hero.tsx
index bd9bc5d..469996d 100644
--- a/components/landing/hero.tsx
+++ b/components/landing/hero.tsx
@@ -43,7 +43,7 @@ export function Hero() {
- 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.
diff --git a/components/landing/incident-section.tsx b/components/landing/incident-section.tsx
index 11f5870..66bd57f 100644
--- a/components/landing/incident-section.tsx
+++ b/components/landing/incident-section.tsx
@@ -18,7 +18,7 @@ export function IncidentSection() {
- It writes the postmortem before you open the trace.
+ It writes the postmortem before you open the dashboard.
A rolling z-score model flags anomalies in error rate and p99 latency, then writes a plain-English summary automatically.
diff --git a/components/landing/navbar.tsx b/components/landing/navbar.tsx
index 4df9363..2086ea9 100644
--- a/components/landing/navbar.tsx
+++ b/components/landing/navbar.tsx
@@ -10,7 +10,6 @@ import { primaryButtonClass } from "@/components/landing/button-styles";
const NAV_LINKS = [
{ label: "Features", href: "#features" },
{ label: "Detection", href: "#detection" },
- { label: "Status page", href: "#status" },
];
const GITHUB_URL = "https://github.com/ryzrr/liveboard";
diff --git a/components/landing/stats-section.tsx b/components/landing/stats-section.tsx
index fc10266..a7e9d06 100644
--- a/components/landing/stats-section.tsx
+++ b/components/landing/stats-section.tsx
@@ -3,8 +3,7 @@ import { AnimatedCounter } from "@/components/landing/animated-counter";
const STATS = [
{ target: 2, label: "official SDKs" },
- { target: 5, label: "framework adapters" },
- { target: 4, label: "alert channels" },
+ { target: 3, label: "worker loops" },
];
export function StatsSection() {
@@ -15,11 +14,11 @@ export function StatsSection() {
Built to be adopted in an afternoon.
- No agents, no sidecars, no new infrastructure to run. Two SDKs, five adapters, and a status page for free.
+ No agents, no sidecars, no new infrastructure to run. Two SDKs, one line of middleware, and Docker Compose.
-
+
{STATS.map((stat) => (
diff --git a/components/landing/status-section.tsx b/components/landing/status-section.tsx
deleted file mode 100644
index 07f1f8c..0000000
--- a/components/landing/status-section.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { RevealOnScroll } from "@/components/landing/reveal-on-scroll";
-import { ScreenshotFrame } from "@/components/landing/screenshot-frame";
-
-export function StatusSection() {
- return (
-
-
-
-
- A status page your users actually trust.
-
-
- Generated from the same data you already collect: uptime, incident timelines, and component health, published automatically.
-
-
-
-
-
-
-
-
- );
-}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index fbd4ee3..9c36274 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -5,9 +5,6 @@ import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Zap,
- GitBranch,
- Bell,
- Globe,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -18,9 +15,6 @@ import { UserMenu } from "@/components/auth/user-menu";
const NAV_ITEMS = [
{ href: "/overview", label: "Overview", icon: LayoutDashboard },
{ href: "/endpoints", label: "Endpoints", icon: Zap },
- { href: "/traces", label: "Traces", icon: GitBranch },
- { href: "/alerts", label: "Alerts", icon: Bell },
- { href: "/status", label: "Status Page", icon: Globe },
];
export function Sidebar() {
@@ -58,11 +52,6 @@ export function Sidebar() {
>
{label}
- {href === "/alerts" && (
-
- 2
-
- )}
);
})}
diff --git a/components/projects/create-project-dialog.tsx b/components/projects/create-project-dialog.tsx
index 5f90911..b052d26 100644
--- a/components/projects/create-project-dialog.tsx
+++ b/components/projects/create-project-dialog.tsx
@@ -95,7 +95,7 @@ export function CreateProjectDialog({ open, onOpenChange }: Props) {
Create project
- A project groups API keys, events, and alert rules.
+ A project groups API keys, events, and incidents.
diff --git a/components/providers/project-provider.tsx b/components/providers/project-provider.tsx
index eee3a0b..145763a 100644
--- a/components/providers/project-provider.tsx
+++ b/components/providers/project-provider.tsx
@@ -17,8 +17,6 @@ export interface Project {
/** Masked display-only key hint: lb_live_abcd•••• */
apiKeyMasked: string;
createdAt: string;
- statusPageEnabled: boolean;
- publicSlug: string | null;
}
export interface CreateProjectResult {
@@ -35,7 +33,6 @@ interface ProjectContextValue {
createProject: (name: string) => Promise
;
deleteProject: (id: string) => Promise;
rotateKey: (id: string) => Promise;
- setStatusPageEnabled: (id: string, enabled: boolean) => Promise<{ enabled: boolean; publicSlug: string | null }>;
refresh: () => Promise;
}
@@ -46,8 +43,6 @@ 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 {
@@ -56,8 +51,6 @@ function fromDTO(d: ProjectDetailDTO): Project {
name: d.name,
apiKeyMasked: d.api_key_masked,
createdAt: d.created_at,
- statusPageEnabled: d.status_page_enabled,
- publicSlug: d.public_slug,
};
}
@@ -143,8 +136,6 @@ 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 };
},
@@ -169,21 +160,6 @@ 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;
@@ -197,7 +173,6 @@ 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
index cc2ba81..aac9ed0 100644
--- a/components/settings/project-settings-card.tsx
+++ b/components/settings/project-settings-card.tsx
@@ -1,33 +1,29 @@
"use client";
import { useState } from "react";
-import { Check, Copy, ExternalLink, RefreshCw, Trash2 } from "lucide-react";
+import { Check, Copy, 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";
+import { 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.
+ * One project's real settings: API key + rotate and a destructive delete —
+ * both backed by the existing ProjectProvider actions (rotateKey /
+ * 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 { rotateKey, deleteProject } = 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);
@@ -54,30 +50,6 @@ export function ProjectSettingsCard({ project }: Props) {
});
}
- 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("");
@@ -143,60 +115,6 @@ export function ProjectSettingsCard({ project }: Props) {
)}
- {/* 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
@@ -208,7 +126,7 @@ export function ProjectSettingsCard({ project }: Props) {
This permanently deletes {project.name} and
- all its events, traces, alert rules, and API keys. Type the project name to confirm.
+ all its events, incidents, and API keys. Type the project name to confirm.
{
- 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
deleted file mode 100644
index 2622928..0000000
--- a/components/status/incident-card.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-"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
deleted file mode 100644
index 6e1299c..0000000
--- a/components/status/overall-banner.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index df547d7..0000000
--- a/components/status/subscribe-form.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"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
deleted file mode 100644
index 42781a0..0000000
--- a/components/status/uptime-bar.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import type { ServiceStatus } from "@/lib/types";
-
-interface UptimeBarProps {
- service: ServiceStatus;
-}
-
-const STATUS_LABELS = {
- operational: "Operational",
- degraded: "Degraded",
- partial_outage: "Partial Outage",
- major_outage: "Major Outage",
-};
-
-const STATUS_COLORS = {
- operational: "text-green",
- degraded: "text-yellow",
- partial_outage: "text-red",
- major_outage: "text-red",
-};
-
-const BAR_COLORS = {
- up: "bg-green/70",
- degraded: "bg-yellow/70",
- down: "bg-red/70",
-};
-
-export function UptimeBar({ service }: UptimeBarProps) {
- return (
-
-
-
-
{service.name}
-
-
-
- {STATUS_LABELS[service.currentStatus]}
-
-
-
-
-
{service.uptime90d.toFixed(2)}%
-
uptime (90d)
-
-
-
- {/* Uptime bars */}
-
- {service.uptimeBars.map((status, i) => (
-
- ))}
-
-
- 90 days ago
- Today
-
-
- );
-}
diff --git a/components/traces/flame-graph.tsx b/components/traces/flame-graph.tsx
deleted file mode 100644
index 2c416c2..0000000
--- a/components/traces/flame-graph.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { cn } from "@/lib/utils";
-import type { Trace, TraceSpan } from "@/lib/types";
-
-interface FlameGraphProps {
- trace: Trace;
- onSpanClick: (span: TraceSpan) => void;
- selectedSpan?: string;
- highlightCriticalPath?: boolean;
-}
-
-function computeCriticalPath(spans: TraceSpan[]): Set {
- const childMap = new Map();
- for (const span of spans) {
- const key = span.parentId;
- if (!childMap.has(key)) childMap.set(key, []);
- childMap.get(key)!.push(span);
- }
-
- const criticalIds = new Set();
-
- function walk(spanId: string | undefined): number {
- const children = childMap.get(spanId) ?? [];
- if (children.length === 0) {
- if (spanId) criticalIds.add(spanId);
- return 0;
- }
- let maxChildDuration = -1;
- let bestChild: TraceSpan | null = null;
- for (const child of children) {
- const subtreeDuration = child.duration + walk(child.id);
- if (subtreeDuration > maxChildDuration) {
- maxChildDuration = subtreeDuration;
- bestChild = child;
- }
- }
- if (bestChild) criticalIds.add(bestChild.id);
- if (spanId) criticalIds.add(spanId);
- return maxChildDuration;
- }
-
- const roots = spans.filter((s) => !s.parentId);
- for (const root of roots) walk(root.id);
- return criticalIds;
-}
-
-const SERVICE_COLORS: Record = {
- "api-gateway": "#378ADD",
- "auth-service": "#A855F7",
- "product-service": "#22C55E",
- "payment-service": "#F59E0B",
- "db-postgres": "#6366F1",
- "cache-redis": "#EF4444",
-};
-
-export function FlameGraph({ trace, onSpanClick, selectedSpan, highlightCriticalPath }: FlameGraphProps) {
- const totalDuration = trace.totalDuration;
- const criticalPath = useMemo(
- () => (highlightCriticalPath ? computeCriticalPath(trace.spans) : null),
- [highlightCriticalPath, trace.spans]
- );
-
- return (
-
- {/* Header */}
-
-
- trace_id:
- {trace.id}
-
-
- {trace.spans.length} spans
-
- {trace.totalDuration}ms
-
-
-
-
- {/* Time ruler */}
-
-
- {[0, 0.25, 0.5, 0.75, 1].map((pct) => (
-
- {Math.round(pct * totalDuration)}ms
-
- ))}
-
-
-
- {/* Spans */}
-
- {trace.spans.map((span) => {
- const left = (span.startTime / totalDuration) * 100;
- const width = Math.max((span.duration / totalDuration) * 100, 0.5);
- const color = SERVICE_COLORS[span.service] || "#888";
- const isSelected = selectedSpan === span.id;
- const isError = span.status === "error";
- const isCritical = criticalPath ? criticalPath.has(span.id) : true;
-
- return (
-
-
- {span.service}
-
-
- onSpanClick(span)}
- className={cn(
- "absolute top-0 h-6 rounded flex items-center px-1.5 transition-all cursor-pointer",
- isSelected ? "ring-1 ring-white/20" : "hover:brightness-110"
- )}
- style={{
- left: `${left}%`,
- width: `${width}%`,
- minWidth: "3px",
- backgroundColor: isError ? "#EF4444" : color,
- opacity: isCritical ? (isSelected ? 1 : 0.85) : 0.2,
- }}
- >
- {width > 8 && (
-
- {span.name}
-
- )}
-
-
-
- {span.duration}ms
-
-
- );
- })}
-
-
- );
-}
diff --git a/components/traces/service-map.tsx b/components/traces/service-map.tsx
deleted file mode 100644
index cf9ee60..0000000
--- a/components/traces/service-map.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import type { Trace } from "@/lib/types";
-
-interface ServiceMapProps {
- trace: Trace;
-}
-
-interface Node {
- service: string;
- x: number;
- y: number;
- callCount: number;
- hasError: boolean;
-}
-
-interface Edge {
- from: string;
- to: string;
- count: number;
-}
-
-const SERVICE_COLORS: Record = {
- "api-gateway": "#378ADD",
- "auth-service": "#A855F7",
- "product-service": "#22C55E",
- "payment-service": "#F59E0B",
- "db-postgres": "#6366F1",
- "cache-redis": "#EF4444",
-};
-
-const W = 480;
-const H = 240;
-const CX = W / 2;
-const CY = H / 2;
-const R = 88;
-const NODE_R = 28;
-
-function buildGraph(trace: Trace): { nodes: Node[]; edges: Edge[] } {
- const spanById = new Map(trace.spans.map((s) => [s.id, s]));
-
- // Collect service→service edges from parent-child relationships
- const edgeMap = new Map();
- for (const span of trace.spans) {
- if (!span.parentId) continue;
- const parent = spanById.get(span.parentId);
- if (!parent || parent.service === span.service) continue;
- const key = `${parent.service}→${span.service}`;
- edgeMap.set(key, (edgeMap.get(key) ?? 0) + 1);
- }
-
- // Build unique services
- const serviceSet = new Set(trace.spans.map((s) => s.service));
- const services = Array.from(serviceSet);
-
- // Find root service (span with no parentId)
- const rootSpan = trace.spans.find((s) => !s.parentId);
- const rootService = rootSpan?.service ?? services[0]!;
-
- // Layout: root at center, others in a circle
- const others = services.filter((s) => s !== rootService);
- const angleStep = others.length > 0 ? (2 * Math.PI) / others.length : 0;
-
- const posMap = new Map();
- posMap.set(rootService, { x: CX, y: CY });
- others.forEach((svc, i) => {
- const angle = i * angleStep - Math.PI / 2;
- posMap.set(svc, {
- x: CX + R * Math.cos(angle),
- y: CY + R * Math.sin(angle),
- });
- });
-
- const nodes: Node[] = services.map((svc) => ({
- service: svc,
- x: posMap.get(svc)!.x,
- y: posMap.get(svc)!.y,
- callCount: trace.spans.filter((s) => s.service === svc).length,
- hasError: trace.spans.some((s) => s.service === svc && s.status === "error"),
- }));
-
- const edges: Edge[] = Array.from(edgeMap.entries()).map(([key, count]) => {
- const [from, to] = key.split("→") as [string, string];
- return { from, to, count };
- });
-
- return { nodes, edges };
-}
-
-function arrowPath(x1: number, y1: number, x2: number, y2: number): string {
- const dx = x2 - x1;
- const dy = y2 - y1;
- const dist = Math.hypot(dx, dy);
- if (dist === 0) return "";
- const ux = dx / dist;
- const uy = dy / dist;
- const sx = x1 + ux * NODE_R;
- const sy = y1 + uy * NODE_R;
- const ex = x2 - ux * NODE_R;
- const ey = y2 - uy * NODE_R;
- return `M ${sx} ${sy} L ${ex} ${ey}`;
-}
-
-export function ServiceMap({ trace }: ServiceMapProps) {
- const { nodes, edges } = useMemo(() => buildGraph(trace), [trace]);
-
- return (
-
-
Service Map
-
-
-
-
-
-
-
-
- {/* Edges */}
- {edges.map((edge) => {
- const fromNode = nodes.find((n) => n.service === edge.from);
- const toNode = nodes.find((n) => n.service === edge.to);
- if (!fromNode || !toNode) return null;
- return (
-
-
- {edge.count > 1 && (
-
- ×{edge.count}
-
- )}
-
- );
- })}
-
- {/* Nodes */}
- {nodes.map((node) => {
- const color = SERVICE_COLORS[node.service] ?? "#888";
- const isRoot = !trace.spans.find((s) => s.service === node.service && s.parentId);
- return (
-
- {/* Outer ring for root */}
- {isRoot && (
-
- )}
-
- {/* Service name — shortened */}
-
- {node.service.split("-").map((part, i) => (
-
- {part}
-
- ))}
-
- {/* Call count badge */}
- {node.callCount > 1 && (
-
- {node.callCount}
-
- )}
-
- );
- })}
-
-
-
- {/* Legend */}
-
- {nodes.map((node) => {
- const color = SERVICE_COLORS[node.service] ?? "#888";
- return (
-
-
-
{node.service}
- {node.hasError &&
! }
-
- );
- })}
-
-
- );
-}
diff --git a/components/traces/span-detail.tsx b/components/traces/span-detail.tsx
deleted file mode 100644
index 9586965..0000000
--- a/components/traces/span-detail.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-"use client";
-
-import { motion, AnimatePresence } from "framer-motion";
-import { X } from "lucide-react";
-import { cn } from "@/lib/utils";
-import type { TraceSpan } from "@/lib/types";
-
-interface SpanDetailProps {
- span: TraceSpan | null;
- onClose: () => void;
-}
-
-const SERVICE_COLORS: Record = {
- "api-gateway": "#378ADD",
- "auth-service": "#A855F7",
- "product-service": "#22C55E",
- "payment-service": "#F59E0B",
- "db-postgres": "#6366F1",
- "cache-redis": "#EF4444",
-};
-
-export function SpanDetail({ span, onClose }: SpanDetailProps) {
- return (
-
- {span && (
-
-
-
-
- {[
- { label: "Span ID", value: span.id, mono: true },
- { label: "Service", value: span.service, mono: true },
- { label: "Start Time", value: `+${span.startTime}ms`, mono: true },
- { label: "Duration", value: `${span.duration}ms`, mono: true },
- { label: "Status", value: span.status, color: span.status === "error" ? "#EF4444" : "#22C55E" },
- { label: "Parent", value: span.parentId || "root", mono: true },
- ].map((field) => (
-
-
{field.label}
-
- {field.value}
-
-
- ))}
-
-
- {Object.keys(span.tags).length > 0 && (
-
-
Tags
-
- {Object.entries(span.tags).map(([k, v]) => (
-
- {k}
- =
- {v}
-
- ))}
-
-
- )}
-
- )}
-
- );
-}
diff --git a/hooks/use-data.ts b/hooks/use-data.ts
index c7dedad..e52e96f 100644
--- a/hooks/use-data.ts
+++ b/hooks/use-data.ts
@@ -10,16 +10,10 @@
*/
import { useApiQuery, EMPTY_LIST } from "@/hooks/use-api-query";
-import { apiMutate } from "@/lib/api-client";
-import { useProjects } from "@/components/providers/project-provider";
import type {
- AlertRule,
ChartData,
Endpoint,
Incident,
- ServiceStatus,
- Trace,
- TraceSpan,
} from "@/lib/types";
// ─── Raw API shapes (snake_case) ─────────────────────────────────────────────
@@ -47,38 +41,6 @@ interface ApiIncident {
resolved: boolean;
}
-interface ApiSpan {
- id: string;
- trace_id: string;
- parent_id?: string;
- service: string;
- name: string;
- start_time: number;
- duration: number;
- status: string;
- tags: Record;
-}
-
-interface ApiTrace {
- id: string;
- root_span: string;
- service: string;
- endpoint: string;
- total_duration: number;
- timestamp: string;
- status: string;
- spans: ApiSpan[];
-}
-
-interface ApiService {
- id: string;
- name: string;
- uptime90d: number;
- current_status: string;
- response_time: number;
- uptime_bars: string[];
-}
-
// ─── Transforms ──────────────────────────────────────────────────────────────
function toEndpoints(raw: unknown): Endpoint[] {
@@ -108,42 +70,6 @@ function toIncidents(raw: unknown): Incident[] {
}));
}
-function toTraces(raw: unknown): Trace[] {
- return (raw as ApiTrace[]).map((t) => ({
- id: t.id,
- rootSpan: t.root_span,
- service: t.service,
- endpoint: t.endpoint,
- totalDuration: t.total_duration,
- timestamp: new Date(t.timestamp),
- status: t.status as Trace["status"],
- spans: t.spans.map(
- (s): TraceSpan => ({
- id: s.id,
- traceId: s.trace_id,
- parentId: s.parent_id,
- service: s.service,
- name: s.name,
- startTime: s.start_time,
- duration: s.duration,
- status: s.status as TraceSpan["status"],
- tags: s.tags,
- })
- ),
- }));
-}
-
-function toServices(raw: unknown): ServiceStatus[] {
- return (raw as ApiService[]).map((s) => ({
- id: s.id,
- name: s.name,
- uptime90d: s.uptime90d,
- currentStatus: s.current_status as ServiceStatus["currentStatus"],
- responseTime: s.response_time,
- uptimeBars: s.uptime_bars as ServiceStatus["uptimeBars"],
- }));
-}
-
// ─── Hooks ───────────────────────────────────────────────────────────────────
/** Request-volume and status-code breakdown for the overview charts. Polls every 30 s. */
@@ -171,203 +97,3 @@ export function useEndpoints(hours = 24) {
toEndpoints,
);
}
-
-/** 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() {
- return useApiQuery("/v1/services", {}, EMPTY_LIST as unknown as ServiceStatus[], toServices, 30_000);
-}
-
-// ─── Alert rules ─────────────────────────────────────────────────────────────
-
-interface ApiAlertRule {
- id: string;
- name: string;
- metric: string;
- operator: string;
- threshold: number;
- window: number;
- severity: string;
- channel: string;
- status: string;
- enabled: boolean;
- last_triggered: string | null;
-}
-
-interface ApiAlertHistory {
- id: string;
- rule_name: string;
- fired_at: string;
- resolved_at: string | null;
- channel: string;
- resolved: boolean;
- duration: string;
-}
-
-export interface AlertHistoryEntry {
- id: string;
- ruleName: string;
- firedAt: Date;
- resolvedAt: Date | null;
- channel: string;
- resolved: boolean;
- duration: string;
-}
-
-export interface CreateAlertRulePayload {
- name: string;
- metric: string;
- operator: ">" | "<" | "=" | "!=";
- threshold: number;
- window: number;
- severity: "critical" | "warning" | "info";
- channel: string;
-}
-
-function toAlertRule(r: ApiAlertRule): AlertRule {
- return {
- id: r.id,
- name: r.name,
- metric: r.metric,
- operator: r.operator as AlertRule["operator"],
- threshold: r.threshold,
- window: r.window,
- severity: r.severity as AlertRule["severity"],
- channel: r.channel,
- status: r.status as AlertRule["status"],
- enabled: r.enabled,
- lastTriggered: r.last_triggered ? new Date(r.last_triggered) : undefined,
- };
-}
-
-function toAlertRules(raw: unknown): AlertRule[] {
- return (raw as ApiAlertRule[]).map(toAlertRule);
-}
-
-function toAlertHistory(raw: unknown): AlertHistoryEntry[] {
- return (raw as ApiAlertHistory[]).map((h) => ({
- id: h.id,
- ruleName: h.rule_name,
- firedAt: new Date(h.fired_at),
- resolvedAt: h.resolved_at ? new Date(h.resolved_at) : null,
- channel: h.channel,
- resolved: h.resolved,
- duration: h.duration,
- }));
-}
-
-/** Alert rules for the current project — with create/toggle helpers. */
-export function useAlertRules() {
- const { activeProject } = useProjects();
- const project = activeProject?.id;
- // Poll so a rule flipping to "firing" (by the evaluation worker) shows up live.
- 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);
- return toAlertRule(raw);
- }
-
- async function toggleRule(id: string, enabled: boolean): Promise {
- await apiMutate("PATCH", `/v1/alert-rules/${id}`, { enabled }, project);
- }
-
- async function deleteRule(id: string): Promise {
- await apiMutate("DELETE", `/v1/alert-rules/${id}`, undefined, project);
- }
-
- return { ...query, createRule, toggleRule, deleteRule };
-}
-
-/** Alert firing history for the current project. */
-export function useAlertHistory() {
- return useApiQuery(
- "/v1/alert-history", {}, EMPTY_LIST as unknown as AlertHistoryEntry[], toAlertHistory, 15_000,
- );
-}
-
-// ─── Alert channels (real delivery targets) ──────────────────────────────────
-
-export interface AlertChannel {
- id: string;
- type: "slack" | "discord" | "pagerduty" | "webhook";
- name: string;
- webhookUrl: string | null;
- enabled: boolean;
- lastDeliveryAt: Date | null;
- lastDeliveryOk: boolean | null;
-}
-
-interface ApiChannel {
- id: string;
- type: string;
- name: string;
- webhook_url: string | null;
- enabled: boolean;
- last_delivery_at: string | null;
- last_delivery_ok: boolean | null;
-}
-
-function toChannel(r: ApiChannel): AlertChannel {
- return {
- id: r.id,
- type: r.type as AlertChannel["type"],
- name: r.name,
- webhookUrl: r.webhook_url,
- enabled: r.enabled,
- lastDeliveryAt: r.last_delivery_at ? new Date(r.last_delivery_at) : null,
- lastDeliveryOk: r.last_delivery_ok,
- };
-}
-
-export interface CreateChannelPayload {
- type: AlertChannel["type"];
- name: string;
- webhook_url: string;
- enabled?: boolean;
-}
-
-/** Real alert delivery channels for the current project. */
-export function useChannels() {
- const { activeProject } = useProjects();
- const project = activeProject?.id;
- const query = useApiQuery(
- "/v1/channels",
- {},
- EMPTY_LIST as unknown as AlertChannel[],
- (raw) => (raw as ApiChannel[]).map(toChannel),
- 15_000,
- );
-
- async function createChannel(input: CreateChannelPayload): Promise {
- const raw = await apiMutate("POST", "/v1/channels", input, project);
- query.refetch();
- return toChannel(raw);
- }
- async function updateChannel(id: string, patch: Partial): Promise {
- await apiMutate("PATCH", `/v1/channels/${id}`, patch, project);
- query.refetch();
- }
- async function deleteChannel(id: string): Promise {
- await apiMutate("DELETE", `/v1/channels/${id}`, undefined, project);
- query.refetch();
- }
- async function testChannel(id: string): Promise<{ ok: boolean; detail: string }> {
- const res = await apiMutate<{ ok: boolean; detail: string }>("POST", `/v1/channels/${id}/test`, {}, project);
- query.refetch();
- return res;
- }
-
- return { ...query, createChannel, updateChannel, deleteChannel, testChannel };
-}
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index f630941..043790c 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -81,14 +81,10 @@ services:
dockerfile: Dockerfile.frontend
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL}
- NEXT_PUBLIC_LIVEBOARD_API_KEY: ${NEXT_PUBLIC_LIVEBOARD_API_KEY}
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL}
- NEXT_PUBLIC_LIVEBOARD_API_KEY: ${NEXT_PUBLIC_LIVEBOARD_API_KEY}
NEXTAUTH_URL: ${NEXTAUTH_URL}
AUTH_SECRET: ${AUTH_SECRET}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
diff --git a/lib/project-access.ts b/lib/project-access.ts
index b903ae8..15bfce4 100644
--- a/lib/project-access.ts
+++ b/lib/project-access.ts
@@ -13,7 +13,7 @@ function internalToken(): string {
* 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
+ * not a stale cache" pattern already used by the rotate/delete BFF
* routes (`_require_member` on the backend side).
*/
export async function verifyProjectAccess(email: string, projectId: string): Promise {
diff --git a/lib/types.ts b/lib/types.ts
index 31c287a..815a5d8 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -42,52 +42,6 @@ export interface Endpoint {
healthScore: number;
}
-export interface TraceSpan {
- id: string;
- traceId: string;
- parentId?: string;
- service: string;
- name: string;
- startTime: number;
- duration: number;
- status: "ok" | "error";
- tags: Record;
-}
-
-export interface Trace {
- id: string;
- rootSpan: string;
- service: string;
- endpoint: string;
- totalDuration: number;
- timestamp: Date;
- status: "ok" | "error";
- spans: TraceSpan[];
-}
-
-export interface AlertRule {
- id: string;
- name: string;
- metric: string;
- operator: ">" | "<" | "=" | "!=";
- threshold: number;
- window: number;
- severity: "critical" | "warning" | "info";
- channel: string;
- status: "firing" | "ok" | "pending";
- lastTriggered?: Date;
- enabled: boolean;
-}
-
-export interface ServiceStatus {
- id: string;
- name: string;
- uptime90d: number;
- currentStatus: "operational" | "degraded" | "partial_outage" | "major_outage";
- responseTime: number;
- uptimeBars: ("up" | "degraded" | "down")[];
-}
-
export interface MetricPoint {
time: string;
value: number;
diff --git a/packages/sdk-js/package.json b/packages/sdk-js/package.json
index 7891065..8ff1a6d 100644
--- a/packages/sdk-js/package.json
+++ b/packages/sdk-js/package.json
@@ -1,7 +1,7 @@
{
"name": "liveboard-sdk",
"version": "0.1.0",
- "description": "LiveBoard SDK — zero-config API observability for Express, Fastify, and more",
+ "description": "LiveBoard SDK — zero-config API observability for Express",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
- "keywords": ["api", "observability", "monitoring", "express", "fastify", "liveboard"],
+ "keywords": ["api", "observability", "monitoring", "express", "liveboard"],
"license": "MIT",
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
@@ -32,11 +32,9 @@
"typescript": "^5.0.0"
},
"peerDependencies": {
- "express": ">=4.0.0",
- "fastify": ">=4.0.0"
+ "express": ">=4.0.0"
},
"peerDependenciesMeta": {
- "express": { "optional": true },
- "fastify": { "optional": true }
+ "express": { "optional": true }
}
}
diff --git a/packages/sdk-js/src/buffer.ts b/packages/sdk-js/src/buffer.ts
index 6277baa..d2c0b2b 100644
--- a/packages/sdk-js/src/buffer.ts
+++ b/packages/sdk-js/src/buffer.ts
@@ -1,5 +1,5 @@
-import type { EventPayload, IngestBatch, SpanPayload, SpanBatch } from "./types";
-import { sendBatch, sendSpans } from "./client";
+import type { EventPayload, IngestBatch } from "./types";
+import { sendBatch } from "./client";
export class EventBuffer {
private queue: EventPayload[] = [];
@@ -51,48 +51,3 @@ export class EventBuffer {
this.flush();
}
}
-
-/** Batches spans and flushes them to /v1/spans on an interval or when full. */
-export class SpanBuffer {
- private queue: SpanPayload[] = [];
- private timer: ReturnType | null = null;
- private flushing = false;
-
- constructor(
- private readonly ingestUrl: string,
- private readonly apiKey: string,
- private readonly batchSize: number,
- flushIntervalMs: number
- ) {
- this.timer = setInterval(() => this.flush(), flushIntervalMs);
- if (this.timer.unref) this.timer.unref();
-
- const shutdown = () => this.flush();
- process.once("SIGTERM", shutdown);
- process.once("SIGINT", shutdown);
- }
-
- add(span: SpanPayload): void {
- this.queue.push(span);
- // Backend accepts up to 500 spans per batch.
- if (this.queue.length >= Math.min(this.batchSize, 500)) {
- this.flush();
- }
- }
-
- flush(): void {
- if (this.flushing || this.queue.length === 0) return;
- this.flushing = true;
- const batch: SpanBatch = { spans: this.queue.splice(0) };
- sendSpans(this.ingestUrl, this.apiKey, batch);
- this.flushing = false;
- }
-
- destroy(): void {
- if (this.timer) {
- clearInterval(this.timer);
- this.timer = null;
- }
- this.flush();
- }
-}
diff --git a/packages/sdk-js/src/client.ts b/packages/sdk-js/src/client.ts
index 3afc6b7..6cd9342 100644
--- a/packages/sdk-js/src/client.ts
+++ b/packages/sdk-js/src/client.ts
@@ -1,4 +1,4 @@
-import type { IngestBatch, SpanBatch } from "./types";
+import type { IngestBatch } from "./types";
import * as https from "https";
import * as http from "http";
@@ -48,8 +48,3 @@ function post(ingestUrl: string, path: string, apiKey: string, payload: unknown)
export function sendBatch(ingestUrl: string, apiKey: string, batch: IngestBatch): void {
post(ingestUrl, "/v1/ingest", apiKey, batch);
}
-
-/** POST a batch of spans to /v1/spans. */
-export function sendSpans(ingestUrl: string, apiKey: string, batch: SpanBatch): void {
- post(ingestUrl, "/v1/spans", apiKey, batch);
-}
diff --git a/packages/sdk-js/src/config.ts b/packages/sdk-js/src/config.ts
index 5f45374..33cc653 100644
--- a/packages/sdk-js/src/config.ts
+++ b/packages/sdk-js/src/config.ts
@@ -9,8 +9,6 @@ const DEFAULTS = {
redactHeaders: ["authorization", "cookie"],
flushInterval: 500,
batchSize: 100,
- service: "api",
- tracing: true,
} as const;
export function resolveConfig(config: LiveBoardConfig): ResolvedConfig {
@@ -29,8 +27,6 @@ export function resolveConfig(config: LiveBoardConfig): ResolvedConfig {
getUserId: config.getUserId ?? extractUserId,
flushInterval: config.flushInterval ?? DEFAULTS.flushInterval,
batchSize: config.batchSize ?? DEFAULTS.batchSize,
- service: config.service ?? DEFAULTS.service,
- tracing: config.tracing ?? DEFAULTS.tracing,
};
}
diff --git a/packages/sdk-js/src/express.ts b/packages/sdk-js/src/express.ts
index 5a0378b..1632091 100644
--- a/packages/sdk-js/src/express.ts
+++ b/packages/sdk-js/src/express.ts
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "http";
import type { LiveBoardConfig } from "./types";
import { resolveConfig, shouldIgnore, shouldSample } from "./config";
import { getRoute } from "./normalise";
-import { EventBuffer, SpanBuffer } from "./buffer";
+import { EventBuffer } from "./buffer";
import { SDK_VERSION } from "./version";
// Minimal Express-compatible types — avoids a hard @types/express dependency
@@ -25,9 +25,6 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware {
resolved.batchSize,
resolved.flushInterval
);
- const spanBuffer = resolved.tracing
- ? new SpanBuffer(resolved.ingestUrl, resolved.apiKey, resolved.batchSize, resolved.flushInterval)
- : null;
return function liveboardMiddleware(
req: IncomingMessage,
@@ -35,20 +32,11 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware {
next: NextFunction
): void {
const startMs = Date.now();
- // Propagate incoming trace ID from upstream service, or start a new trace
+ // Reuse an upstream request's correlation id when present, else mint one.
+ // Echoed back on the response so a caller can correlate its own logs.
const incoming = req.headers["x-trace-id"];
const traceId = (typeof incoming === "string" && incoming) ? incoming : randomUUID();
-
- // If an upstream instrumented service passed its span id, parent to it.
- const incomingParent = req.headers["x-parent-span-id"];
- const parentId = typeof incomingParent === "string" && incomingParent ? incomingParent : undefined;
-
- // This request's root span id — propagated to downstream services so they
- // can attach their spans under it (enables cross-service traces).
- const spanId = randomUUID();
-
res.setHeader("x-trace-id", traceId);
- res.setHeader("x-parent-span-id", spanId);
res.on("finish", () => {
const route = getRoute(req);
@@ -74,21 +62,6 @@ export function createExpressMiddleware(config: LiveBoardConfig): Middleware {
: undefined,
sdk_version: SDK_VERSION,
});
-
- // Emit a root span for this request → powers the trace / flame-graph view.
- if (spanBuffer) {
- spanBuffer.add({
- span_id: spanId,
- trace_id: traceId,
- parent_id: parentId,
- service_name: resolved.service,
- operation: `${method} ${route}`,
- start_time: new Date(startMs).toISOString(),
- duration_ms: durationMs,
- status_code: statusCode,
- tags: { method, route },
- });
- }
});
next();
diff --git a/packages/sdk-js/src/fastify.ts b/packages/sdk-js/src/fastify.ts
deleted file mode 100644
index ca5b7a9..0000000
--- a/packages/sdk-js/src/fastify.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { randomUUID } from "crypto";
-import type { LiveBoardConfig } from "./types";
-import { resolveConfig, shouldIgnore, shouldSample } from "./config";
-import { EventBuffer } from "./buffer";
-import { SDK_VERSION } from "./version";
-
-// Minimal Fastify types to avoid a hard peer-dep import at runtime
-type FastifyInstance = {
- addHook: (event: string, fn: (...args: any[]) => any) => void;
-};
-type FastifyRequest = {
- method: string;
- url: string;
- routerPath?: string;
- headers: Record;
- raw: import("http").IncomingMessage;
-};
-type FastifyReply = {
- statusCode: number;
- header: (name: string, value: string) => void;
-};
-
-/** Symbol used to stash per-request timing data on the request object. */
-const kStart = Symbol("liveboard.start");
-const kTraceId = Symbol("liveboard.traceId");
-
-/**
- * Fastify plugin for LiveBoard observability.
- *
- * Usage:
- * await fastify.register(liveboardPlugin, { apiKey: 'lb_live_...' });
- */
-export function createFastifyPlugin(config: LiveBoardConfig) {
- const resolved = resolveConfig(config);
- const buffer = new EventBuffer(
- resolved.ingestUrl,
- resolved.apiKey,
- resolved.batchSize,
- resolved.flushInterval
- );
-
- return async function liveboardPlugin(
- fastify: FastifyInstance,
- _opts: unknown
- ): Promise {
- fastify.addHook("onRequest", (request: any, reply: any, done: () => void) => {
- request[kStart] = Date.now();
- // Propagate incoming trace ID from upstream service, or start a new trace
- const incoming = request.headers["x-trace-id"];
- request[kTraceId] = (typeof incoming === "string" && incoming) ? incoming : randomUUID();
- reply.header("x-trace-id", request[kTraceId]);
- done();
- });
-
- fastify.addHook(
- "onResponse",
- (request: any, reply: any, done: () => void) => {
- const route: string = request.routerPath ?? request.url ?? "/";
-
- if (!shouldIgnore(route, resolved.ignoreRoutes) && shouldSample(resolved.sampleRate)) {
- const durationMs = Date.now() - (request[kStart] as number ?? Date.now());
-
- buffer.add({
- method: (request.method as string).toUpperCase(),
- route,
- status_code: reply.statusCode as number,
- duration_ms: durationMs,
- trace_id: request[kTraceId] as string,
- user_id: resolved.getUserId(request.raw),
- timestamp: new Date().toISOString(),
- error_msg:
- reply.statusCode >= 500
- ? String(request.__liveboardError ?? "").slice(0, 500) || undefined
- : undefined,
- sdk_version: SDK_VERSION,
- });
- }
-
- done();
- }
- );
- };
-}
diff --git a/packages/sdk-js/src/index.ts b/packages/sdk-js/src/index.ts
index c6fc427..6ef92f6 100644
--- a/packages/sdk-js/src/index.ts
+++ b/packages/sdk-js/src/index.ts
@@ -1,9 +1,7 @@
import { createExpressMiddleware } from "./express";
-import { createFastifyPlugin } from "./fastify";
export { createExpressMiddleware as middleware };
-export { createFastifyPlugin as fastifyPlugin };
export type { LiveBoardConfig, EventPayload, ResolvedConfig } from "./types";
export { SDK_VERSION } from "./version";
-export default { middleware: createExpressMiddleware, fastifyPlugin: createFastifyPlugin };
+export default { middleware: createExpressMiddleware };
diff --git a/packages/sdk-js/src/normalise.ts b/packages/sdk-js/src/normalise.ts
index 8b3c214..0d1966b 100644
--- a/packages/sdk-js/src/normalise.ts
+++ b/packages/sdk-js/src/normalise.ts
@@ -29,8 +29,8 @@ export function normaliseUrl(rawUrl: string): string {
/**
* Get the route pattern from a request.
- * Prefers the framework's own route pattern (Express: req.route.path,
- * Fastify: req.routerPath) — falls back to regex normalisation.
+ * Prefers the framework's own route pattern (Express: req.route.path) and
+ * falls back to regex normalisation.
*/
export function getRoute(req: IncomingMessage): string {
// Express attaches matched route pattern to req.route
@@ -38,10 +38,6 @@ export function getRoute(req: IncomingMessage): string {
const expressRoute: string = (req as any).route?.path ?? "";
if (expressRoute) return expressBase + expressRoute;
- // Fastify attaches routerPath
- const fastifyPath: string = (req as any).routerPath ?? "";
- if (fastifyPath) return fastifyPath;
-
// Fallback: normalise the raw URL
return normaliseUrl(req.url ?? "/");
}
diff --git a/packages/sdk-js/src/types.ts b/packages/sdk-js/src/types.ts
index 8b20925..632cb02 100644
--- a/packages/sdk-js/src/types.ts
+++ b/packages/sdk-js/src/types.ts
@@ -27,16 +27,6 @@ export interface LiveBoardConfig {
flushInterval?: number;
/** Flush early when the buffer reaches this many events. Default: 100 */
batchSize?: number;
- /**
- * Service name attached to emitted spans (shown in the trace/flame view).
- * Default: 'api'
- */
- service?: string;
- /**
- * Emit a distributed-tracing span per request. Default: true.
- * Set false to send only request events (no spans).
- */
- tracing?: boolean;
}
// ─── Internal resolved config (all fields required + defaults applied) ────────
@@ -49,8 +39,6 @@ export interface ResolvedConfig {
getUserId: (req: IncomingMessage) => string | undefined;
flushInterval: number;
batchSize: number;
- service: string;
- tracing: boolean;
}
// ─── Wire format — must match apps/api/api/schemas.py EventPayload exactly ───
@@ -60,7 +48,7 @@ export interface EventPayload {
route: string;
status_code: number;
duration_ms: number;
- trace_id: string;
+ trace_id: string; // request correlation id
user_id?: string;
timestamp: string; // ISO 8601 UTC
error_msg?: string; // max 500 chars, only when status ≥ 500
@@ -70,21 +58,3 @@ export interface EventPayload {
export interface IngestBatch {
events: EventPayload[];
}
-
-// ─── Span wire format — must match apps/api/api/schemas.py SpanIn exactly ─────
-
-export interface SpanPayload {
- span_id: string;
- trace_id: string;
- parent_id?: string;
- service_name: string;
- operation: string;
- start_time: string; // ISO 8601 UTC
- duration_ms: number;
- status_code: number;
- tags: Record;
-}
-
-export interface SpanBatch {
- spans: SpanPayload[];
-}
diff --git a/packages/sdk-python/liveboard/__init__.py b/packages/sdk-python/liveboard/__init__.py
index b2029ed..763a553 100644
--- a/packages/sdk-python/liveboard/__init__.py
+++ b/packages/sdk-python/liveboard/__init__.py
@@ -6,15 +6,6 @@
FastAPI / Starlette (ASGI):
from liveboard.asgi import LiveBoardMiddleware
app.add_middleware(LiveBoardMiddleware, api_key="lb_live_...")
-
-Django:
- # settings.py
- MIDDLEWARE = [..., "liveboard.django.LiveBoardMiddleware"]
- LIVEBOARD = {"API_KEY": "lb_live_..."}
-
-Flask:
- from liveboard.flask import init_liveboard
- init_liveboard(app, api_key="lb_live_...")
"""
from ._version import SDK_VERSION
@@ -25,9 +16,7 @@
__all__ = [
"SDK_VERSION",
"LiveBoardConfig",
- # Framework adapters are imported from their own submodules to avoid
- # pulling in framework-specific code when only one framework is installed:
- # from liveboard.asgi import LiveBoardMiddleware
- # from liveboard.django import LiveBoardMiddleware
- # from liveboard.flask import init_liveboard
+ # The ASGI adapter lives in its own submodule so importing this package
+ # never pulls in framework-specific code:
+ # from liveboard.asgi import LiveBoardMiddleware
]
diff --git a/packages/sdk-python/liveboard/_buffer.py b/packages/sdk-python/liveboard/_buffer.py
index 0353393..080ec62 100644
--- a/packages/sdk-python/liveboard/_buffer.py
+++ b/packages/sdk-python/liveboard/_buffer.py
@@ -1,10 +1,9 @@
from __future__ import annotations
import asyncio
-import threading
from typing import Any, Optional
-from ._client import send_batch_async, send_batch_sync
+from ._client import send_batch_async
class AsyncEventBuffer:
@@ -63,50 +62,3 @@ async def flush_and_close(self) -> None:
if self._queue:
batch, self._queue = self._queue, []
await send_batch_async(self._ingest_url, self._api_key, batch)
-
-
-class SyncEventBuffer:
- """
- Non-blocking event buffer for sync frameworks (Django / Flask).
-
- Uses a daemon threading.Timer chain for periodic flushing and a
- threading.Lock to protect the queue across concurrent requests.
- """
-
- def __init__(
- self,
- ingest_url: str,
- api_key: str,
- batch_size: int = 100,
- flush_interval: float = 0.5,
- ) -> None:
- self._ingest_url = ingest_url
- self._api_key = api_key
- self._batch_size = batch_size
- self._flush_interval = flush_interval
- self._queue: list[dict[str, Any]] = []
- self._lock = threading.Lock()
- self._schedule_next()
-
- def _schedule_next(self) -> None:
- t = threading.Timer(self._flush_interval, self._tick)
- t.daemon = True
- t.start()
-
- def _tick(self) -> None:
- self._flush()
- self._schedule_next()
-
- def add(self, event: dict[str, Any]) -> None:
- with self._lock:
- self._queue.append(event)
- ready = len(self._queue) >= self._batch_size
- if ready:
- self._flush()
-
- def _flush(self) -> None:
- with self._lock:
- if not self._queue:
- return
- batch, self._queue = self._queue, []
- send_batch_sync(self._ingest_url, self._api_key, batch)
diff --git a/packages/sdk-python/liveboard/_client.py b/packages/sdk-python/liveboard/_client.py
index 77aff0e..fc869a6 100644
--- a/packages/sdk-python/liveboard/_client.py
+++ b/packages/sdk-python/liveboard/_client.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import threading
from typing import Any
import httpx
@@ -19,21 +18,3 @@ async def send_batch_async(ingest_url: str, api_key: str, events: list[dict[str,
)
except Exception:
pass
-
-
-def send_batch_sync(ingest_url: str, api_key: str, events: list[dict[str, Any]]) -> None:
- """Fire-and-forget sync POST in a daemon thread. Silently drops all errors."""
-
- def _worker() -> None:
- try:
- with httpx.Client(timeout=_TIMEOUT) as client:
- client.post(
- f"{ingest_url}/v1/ingest",
- json={"events": events},
- headers={"x-api-key": api_key},
- )
- except Exception:
- pass
-
- t = threading.Thread(target=_worker, daemon=True)
- t.start()
diff --git a/packages/sdk-python/liveboard/_normalise.py b/packages/sdk-python/liveboard/_normalise.py
index 120c1f0..c3a0d85 100644
--- a/packages/sdk-python/liveboard/_normalise.py
+++ b/packages/sdk-python/liveboard/_normalise.py
@@ -12,10 +12,6 @@
_INTEGER = re.compile(r"^\d+$")
_MIXED_ID = re.compile(r"^[a-zA-Z0-9_-]{6,}$")
-# Django: or → :pk / :slug
-_DJANGO_PARAM = re.compile(r"<(?:[^:>]+:)?([^>]+)>")
-# Flask: or → :user_id
-_FLASK_PARAM = re.compile(r"<(?:[^:>]+:)?([^>]+)>")
def _is_id_segment(segment: str) -> bool:
@@ -52,26 +48,6 @@ def route_from_path_params(path: str, path_params: dict[str, str]) -> str:
return route
-def normalise_django_pattern(pattern: str) -> str:
- """
- Convert a Django URL pattern to a normalised route.
- users//posts// → /users/:user_id/posts/:post_slug
- """
- route = pattern.rstrip("/")
- route = _DJANGO_PARAM.sub(lambda m: f":{m.group(1)}", route)
- # Catch any remaining raw regex groups
- route = re.sub(r"\([^)]+\)", ":id", route)
- return route if route.startswith("/") else f"/{route}"
-
-
-def normalise_flask_rule(rule: str) -> str:
- """
- Convert a Flask URL rule to a normalised route.
- /users/ → /users/:user_id
- """
- return _FLASK_PARAM.sub(lambda m: f":{m.group(1)}", rule)
-
-
def extract_user_id(authorization: Optional[str]) -> Optional[str]:
"""
Extract the `sub` claim from a Bearer JWT without verifying the signature.
diff --git a/packages/sdk-python/liveboard/django.py b/packages/sdk-python/liveboard/django.py
deleted file mode 100644
index 85fc71c..0000000
--- a/packages/sdk-python/liveboard/django.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""
-Django middleware for LiveBoard observability.
-
-Usage — settings.py:
-
- MIDDLEWARE = [
- ...
- "liveboard.django.LiveBoardMiddleware",
- ]
-
- LIVEBOARD = {
- "API_KEY": "lb_live_...", # required
- "INGEST_URL": "http://localhost:8000",
- "SAMPLE_RATE": 1.0,
- "IGNORE_ROUTES": ["/health", "/healthz", "/ping", "/favicon.ico"],
- "FLUSH_INTERVAL": 0.5,
- "BATCH_SIZE": 100,
- }
-"""
-from __future__ import annotations
-
-import random
-import time
-import uuid
-from datetime import datetime, timezone
-from typing import Any, Callable, Optional
-
-from ._buffer import SyncEventBuffer
-from ._config import LiveBoardConfig
-from ._normalise import extract_user_id, normalise_django_pattern, normalise_url
-from ._version import SDK_VERSION
-
-
-def _load_config() -> LiveBoardConfig:
- """Read LIVEBOARD dict from Django settings at first request."""
- from django.conf import settings # type: ignore[import-untyped]
-
- cfg: dict[str, Any] = getattr(settings, "LIVEBOARD", {})
- api_key: str = cfg.get("API_KEY", "")
- return LiveBoardConfig(
- api_key=api_key,
- ingest_url=cfg.get("INGEST_URL", "http://localhost:8000"),
- sample_rate=float(cfg.get("SAMPLE_RATE", 1.0)),
- ignore_routes=cfg.get(
- "IGNORE_ROUTES", ["/health", "/healthz", "/ping", "/favicon.ico"]
- ),
- flush_interval=float(cfg.get("FLUSH_INTERVAL", 0.5)),
- batch_size=int(cfg.get("BATCH_SIZE", 100)),
- )
-
-
-class LiveBoardMiddleware:
- """
- Sync Django middleware. Add to MIDDLEWARE in settings.py.
- Config is read from the LIVEBOARD dict in settings.
- """
-
- def __init__(self, get_response: Callable) -> None:
- self._get_response = get_response
- self._config: Optional[LiveBoardConfig] = None
- self._buffer: Optional[SyncEventBuffer] = None
-
- def _ensure_init(self) -> None:
- if self._config is None:
- self._config = _load_config()
- self._buffer = SyncEventBuffer(
- self._config.ingest_url,
- self._config.api_key,
- self._config.batch_size,
- self._config.flush_interval,
- )
-
- def __call__(self, request: Any) -> Any:
- self._ensure_init()
- assert self._config is not None
- assert self._buffer is not None
-
- if self._config.sample_rate < 1.0 and random.random() >= self._config.sample_rate:
- return self._get_response(request)
-
- start_ns = time.perf_counter_ns()
- # Propagate incoming trace ID from upstream service, or start a new trace
- # Django exposes headers as HTTP_X_TRACE_ID in request.META
- trace_id = request.META.get("HTTP_X_TRACE_ID") or str(uuid.uuid4())
-
- response = self._get_response(request)
-
- duration_ms = int((time.perf_counter_ns() - start_ns) / 1_000_000)
-
- # Django sets resolver_match after URL resolution (inside get_response)
- route = self._get_route(request)
-
- if self._should_ignore(route, request.path):
- return response
-
- user_id: Optional[str] = (
- self._config.get_user_id(request)
- if self._config.get_user_id
- else extract_user_id(request.META.get("HTTP_AUTHORIZATION"))
- )
-
- # Inject trace ID header
- response["X-Trace-Id"] = trace_id
-
- self._buffer.add({
- "method": request.method.upper(),
- "route": route,
- "status_code": response.status_code,
- "duration_ms": duration_ms,
- "trace_id": trace_id,
- "user_id": user_id,
- "timestamp": datetime.now(timezone.utc).isoformat(),
- "error_msg": None,
- "sdk_version": SDK_VERSION,
- })
-
- return response
-
- def _get_route(self, request: Any) -> str:
- resolver_match = getattr(request, "resolver_match", None)
- if resolver_match and hasattr(resolver_match, "route") and resolver_match.route:
- return normalise_django_pattern(resolver_match.route)
- return normalise_url(request.path)
-
- def _should_ignore(self, route: str, path: str) -> bool:
- assert self._config is not None
- for pattern in self._config.ignore_routes:
- if isinstance(pattern, str):
- if pattern == route or pattern == path:
- return True
- else:
- if pattern.search(route) or pattern.search(path):
- return True
- return False
diff --git a/packages/sdk-python/liveboard/flask.py b/packages/sdk-python/liveboard/flask.py
deleted file mode 100644
index 3369056..0000000
--- a/packages/sdk-python/liveboard/flask.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""
-Flask extension for LiveBoard observability.
-
-Usage:
- from liveboard.flask import init_liveboard
-
- app = Flask(__name__)
- init_liveboard(app, api_key="lb_live_...")
-"""
-from __future__ import annotations
-
-import random
-import time
-import uuid
-from datetime import datetime, timezone
-from typing import Any, Optional
-
-from ._buffer import SyncEventBuffer
-from ._config import LiveBoardConfig
-from ._normalise import extract_user_id, normalise_flask_rule, normalise_url
-from ._version import SDK_VERSION
-
-# Flask request context globals — imported lazily to avoid a hard dep at module load
-_flask_g: Any = None
-_flask_request: Any = None
-
-
-def _get_flask_globals() -> tuple[Any, Any]:
- global _flask_g, _flask_request
- if _flask_g is None:
- from flask import g, request # type: ignore[import-untyped]
- _flask_g = g
- _flask_request = request
- return _flask_g, _flask_request
-
-
-def init_liveboard(app: Any, *, api_key: str, **kwargs: Any) -> None:
- """
- Register LiveBoard hooks on a Flask application.
-
- All keyword arguments map 1-to-1 to LiveBoardConfig fields:
- ingest_url default: http://localhost:8000
- sample_rate default: 1.0
- ignore_routes default: ['/health', '/healthz', '/ping', '/favicon.ico']
- get_user_id default: JWT sub-claim extraction
- flush_interval default: 0.5s
- batch_size default: 100
- """
- config = LiveBoardConfig(api_key=api_key, **kwargs)
- buffer = SyncEventBuffer(
- config.ingest_url,
- config.api_key,
- config.batch_size,
- config.flush_interval,
- )
-
- @app.before_request
- def _before() -> None:
- g, _ = _get_flask_globals()
- g._lb_skip = (
- config.sample_rate < 1.0 and random.random() >= config.sample_rate
- )
- if not g._lb_skip:
- g._lb_start_ns = time.perf_counter_ns()
- _, req = _get_flask_globals()
- # Propagate incoming trace ID from upstream service, or start a new trace
- g._lb_trace_id = req.headers.get("X-Trace-Id") or str(uuid.uuid4())
-
- @app.after_request
- def _after(response: Any) -> Any:
- g, request = _get_flask_globals()
-
- if getattr(g, "_lb_skip", True):
- return response
-
- duration_ms = int(
- (time.perf_counter_ns() - g._lb_start_ns) / 1_000_000
- )
- trace_id: str = g._lb_trace_id
-
- route = _get_flask_route(request)
-
- if _should_ignore(route, request.path, config):
- return response
-
- user_id: Optional[str] = (
- config.get_user_id(request)
- if config.get_user_id
- else extract_user_id(request.headers.get("Authorization"))
- )
-
- response.headers["X-Trace-Id"] = trace_id
-
- buffer.add({
- "method": request.method.upper(),
- "route": route,
- "status_code": response.status_code,
- "duration_ms": duration_ms,
- "trace_id": trace_id,
- "user_id": user_id,
- "timestamp": datetime.now(timezone.utc).isoformat(),
- "error_msg": None,
- "sdk_version": SDK_VERSION,
- })
-
- return response
-
-
-def _get_flask_route(request: Any) -> str:
- """Prefer Flask's matched URL rule; fall back to regex normalisation."""
- rule = getattr(request, "url_rule", None)
- if rule is not None:
- return normalise_flask_rule(str(rule))
- return normalise_url(request.path)
-
-
-def _should_ignore(route: str, path: str, config: LiveBoardConfig) -> bool:
- for pattern in config.ignore_routes:
- if isinstance(pattern, str):
- if pattern == route or pattern == path:
- return True
- else:
- if pattern.search(route) or pattern.search(path):
- return True
- return False
diff --git a/packages/sdk-python/pyproject.toml b/packages/sdk-python/pyproject.toml
index fa01814..d1cf723 100644
--- a/packages/sdk-python/pyproject.toml
+++ b/packages/sdk-python/pyproject.toml
@@ -5,25 +5,21 @@ build-backend = "setuptools.backends.legacy:build"
[project]
name = "liveboard-sdk"
version = "0.1.0"
-description = "LiveBoard SDK — zero-config API observability for FastAPI, Django, and Flask"
+description = "LiveBoard SDK — zero-config API observability for FastAPI"
license = { text = "MIT" }
requires-python = ">=3.9"
-keywords = ["api", "observability", "monitoring", "fastapi", "django", "flask"]
+keywords = ["api", "observability", "monitoring", "fastapi"]
dependencies = [
"httpx>=0.27.0",
]
[project.optional-dependencies]
fastapi = ["fastapi>=0.100.0", "starlette>=0.27.0"]
-django = ["django>=3.2"]
-flask = ["flask>=2.0"]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.23",
"fastapi>=0.100.0",
"httpx>=0.27.0",
- "django>=3.2",
- "flask>=2.0",
"ruff",
]
diff --git a/proxy.ts b/proxy.ts
index c30de4a..a24413e 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -13,12 +13,6 @@ export const config = {
matcher: [
"/overview/:path*",
"/endpoints/:path*",
- "/traces/:path*",
- "/alerts/: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/public/shots/alerts.png b/public/shots/alerts.png
deleted file mode 100644
index bfa3456..0000000
Binary files a/public/shots/alerts.png and /dev/null differ
diff --git a/public/shots/status.png b/public/shots/status.png
deleted file mode 100644
index ea37b7d..0000000
Binary files a/public/shots/status.png and /dev/null differ
diff --git a/public/shots/traces.png b/public/shots/traces.png
deleted file mode 100644
index ca7eb4f..0000000
Binary files a/public/shots/traces.png and /dev/null differ