feat: phase 3 observability — IMS telemetry merge

Port IMS telemetry into Primora conventions:

- core.components/core.events (migration 00004 + sqlc) scoped to projects
- POST /api/v1/ingest — single or <=500-event batches via project prm_
  keys (X-Primora-Key alias), auto-registering components, permissive CORS
- Issues: fingerprint-grouped errors with count/severity/first+last seen
- Reads: events w/ filters + before-cursor, components, stats (1h/24h/7d/30d
  bucketed series + component health + totals + metric names), metric
  series (avg/p50/p95/max)
- GET .../telemetry/stream — project-scoped SSE through an in-process hub
  (buffered subs, drop-on-slow, 25s keepalives); ?api_key=/?token= auth
  for EventSource
- Telemetry page: health/issues/events/metrics/integrate tabs, live badge,
  390px-clean; demo stubs
- @primora/client SDK (createPrimoraClient: capture*/heartbeat/installAuto,
  queued keepalive flushes)
- MCP: primora_list_issues/list_events/telemetry_stats/metric_series
- IMS README deprecation note pointing here

Also fixes workspace pins to 0.3.0 — npm ci in the frontend image was
resolving the unpublished 0.2.0 spec from the registry.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-16 16:21:03 +02:00
1 parent 6507eee606
commit 8cfddf9184
60 files changed
+3694 -30

No files matched your search

+11
View File
@@ -40,6 +40,7 @@ import {
type AuthUser,
CollectionsPage,
DatabasesPage,
TelemetryPage,
LoginPage,
MembersPage,
ProjectsPage,
@@ -53,6 +54,7 @@ import {
IconMembers,
IconStorage,
IconDatabases,
IconTelemetry,
IconCollections,
IconAudit,
IconAuth,
@@ -1411,6 +1413,7 @@ export default function App() {
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <IconProjects class="w-4 h-4" />, keywords: ["project"], action: () => setActiveView("projects") },
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1624,6 +1627,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "telemetry"}>
<TelemetryPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
+6 -2
View File
@@ -8,6 +8,7 @@ import {
IconStorage,
IconCollections,
IconDatabases,
IconTelemetry,
IconAudit,
IconAuth,
IconSettings,
@@ -26,6 +27,7 @@ export type ViewType =
| "storage"
| "collections"
| "databases"
| "telemetry"
| "auth"
| "audit"
| "settings";
@@ -40,14 +42,15 @@ interface NavEntry {
const projectNav: NavEntry[] = [
{ id: "dashboard", label: "Overview", icon: IconOverview },
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
];
// Instagram-style bottom bar caps at five slots — Collections and Audit stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections");
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects },
@@ -84,6 +87,7 @@ const viewTitle: Record<ViewType, string> = {
storage: "Storage",
collections: "Collections",
databases: "Databases",
telemetry: "Telemetry",
auth: "Authentication",
audit: "Audit log",
settings: "Settings",
+1
View File
@@ -127,6 +127,7 @@ export const IconSun = icon(() => (<><circle cx="12" cy="12" r="4" /><path d="M1
export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />));
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
+136
View File
@@ -17,9 +17,14 @@ import type {
DBConnection,
Document,
MeResponse,
MetricPoint,
OrganizationSummary,
ProjectOverview,
ProjectSummary,
TelemetryComponent,
TelemetryEvent,
TelemetryIssue,
TelemetryStats,
} from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode";
@@ -353,6 +358,23 @@ let dbConnections: DBConnection[] = [
},
];
const demoComponents: TelemetryComponent[] = [
{ id: "cmp-1", name: "web", kind: "frontend", last_seen_at: ago(0.02), last_status: "up", errors_24h: 0, events_24h: 88, meta: {} },
{ id: "cmp-2", name: "api", kind: "backend", last_seen_at: ago(0.014), last_status: "degraded", errors_24h: 4, events_24h: 120, meta: {} },
{ id: "cmp-3", name: "worker", kind: "backend", last_seen_at: ago(0.03), last_status: "up", errors_24h: 0, events_24h: 60, meta: {} },
];
const demoEventsSeed: TelemetryEvent[] = [
{ id: 42, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(0.013) },
{ id: 41, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "metric", severity: "info", message: "", payload: { name: "http.req.ms", value: 132, route: "/projects" }, fingerprint: "", ts: ago(0.02) },
{ id: 40, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "log", severity: "warn", message: "Slow route render: /settings", payload: { ms: 940 }, fingerprint: "", ts: ago(0.03) },
{ id: 39, project_id: "proj-1", component_id: "cmp-3", component_name: "worker", type: "heartbeat", severity: "info", message: "", payload: { status: "up" }, fingerprint: "", ts: ago(0.04) },
{ id: 38, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "event", severity: "info", message: "checkout.completed", payload: { total: 129 }, fingerprint: "", ts: ago(0.06) },
{ id: 37, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(3.5) },
];
let demoEvents: TelemetryEvent[] = [...demoEventsSeed];
const demoTables = [
"users",
"organizations",
@@ -881,6 +903,120 @@ class DemoService {
const cmd = data.requestBody?.command ?? "";
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
}
/* ---- telemetry ---- */
async ingestTelemetry(data: { requestBody?: { events?: Array<Record<string, unknown>> } }) {
await this.delay();
const events = data.requestBody?.events ?? [];
events.forEach((e) => {
demoEvents = [
{
id: (demoEvents[0]?.id ?? 0) + 1,
project_id: "proj-1",
component_id: demoComponents.find((c) => c.name === e.component)?.id ?? null,
component_name: String(e.component ?? ""),
type: String(e.type ?? "event"),
severity: String(e.severity ?? "info"),
message: String(e.message ?? ""),
payload: (e.payload as Record<string, unknown>) ?? {},
fingerprint: String(e.fingerprint ?? ""),
ts: String(e.ts ?? new Date().toISOString()),
},
...demoEvents,
];
});
return { accepted: events.length };
}
async listTelemetryEvents(data: { type?: string; component?: string; fingerprint?: string; limit?: number }) {
await this.delay();
let items = demoEvents;
if (data.type) items = items.filter((e) => e.type === data.type);
if (data.component) items = items.filter((e) => e.component_name === data.component);
if (data.fingerprint) items = items.filter((e) => e.fingerprint === data.fingerprint);
return { items: items.slice(0, data.limit ?? 100) };
}
async listTelemetryIssues() {
await this.delay();
const groups = new Map<string, TelemetryIssue>();
for (const e of demoEvents.filter((e) => e.type === "error" && e.fingerprint)) {
const fp = e.fingerprint!;
const g = groups.get(fp);
if (g) {
g.count += 1;
if (e.ts < g.first_seen) g.first_seen = e.ts;
if (e.ts > g.last_seen) g.last_seen = e.ts;
} else {
groups.set(fp, {
fingerprint: fp,
message: e.message,
component_id: e.component_id,
component_name: e.component_name,
count: 1,
first_seen: e.ts,
last_seen: e.ts,
severity: e.severity,
});
}
}
return { items: [...groups.values()] };
}
async listTelemetryComponents() {
await this.delay();
return { items: demoComponents };
}
async deleteTelemetryComponent(data: { componentId?: string }) {
await this.delay();
return {};
}
async getTelemetryStats(data: { window?: string }): Promise<TelemetryStats> {
await this.delay();
const now = Date.now();
const bucket = 900_000;
const series = Array.from({ length: 12 }, (_, i) => ({
ts: new Date(now - (11 - i) * bucket).toISOString(),
counts: {
error: i === 9 ? 2 : i % 5 === 0 ? 1 : 0,
event: i % 3,
metric: i * 2 + 4,
log: i % 4,
heartbeat: 4,
},
}));
return {
window: 86400,
bucket_sec: 900,
series,
components: demoComponents,
errors: demoEvents.filter((e) => e.type === "error").length,
events: demoEvents.length,
metrics: 46,
metric_names: ["http.req.ms", "db.query.ms"],
};
}
async getTelemetryMetricSeries(): Promise<{ items: MetricPoint[] }> {
await this.delay();
const now = Date.now();
return {
items: Array.from({ length: 12 }, (_, i) => {
const avg = 110 + Math.sin(i / 2) * 40;
return {
ts: new Date(now - (11 - i) * 900_000).toISOString(),
avg,
p50: avg * 0.9,
p95: avg * 1.8,
max: avg * 2.2,
count: 12 + i,
};
}),
};
}
}
export const demoService = new DemoService();
+554
View File
@@ -0,0 +1,554 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import {
OpenAPI,
TelemetryService,
type MetricPoint,
type TelemetryEvent,
type TelemetryIssue,
type TelemetryStats,
} from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal";
import { Select } from "../components/Input";
import {
IconChevronRight,
IconRefresh,
} from "../components/Icons";
interface TelemetryPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
const sevVariant = (s?: string) =>
s === "error" || s === "fatal" ? "error" : s === "warn" || s === "warning" ? "warning" : s === "info" ? "primary" : "neutral";
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
const snippetWeb = `import { createPrimoraClient } from "@primora/client";
const primora = createPrimoraClient({
endpoint: window.location.origin, // your Primora deployment
key: "prm_…", // project API key
component: "web",
kind: "frontend",
});
primora.installAuto(); // window.onerror + unhandledrejection
primora.captureMetric({ name: "app.start.ms", value: performance.now() });`;
const snippetNode = `import { createPrimoraClient } from "@primora/client";
const primora = createPrimoraClient({
endpoint: "https://primora.example.com",
key: process.env.PRIMORA_KEY!, // X-Primora-Key
component: "api",
kind: "backend",
});
setInterval(() => primora.heartbeat("up"), 30_000);
process.on("uncaughtException", (e) => primora.captureError(e));`;
const snippetCurl = `curl -X POST "$ORIGIN/api/v1/ingest" \\
-H "Content-Type: application/json" \\
-H "X-Primora-Key: prm_…" \\
-d '{"type":"event","component":"cli","message":"deploy.done","payload":{"sha":"abc123"}}'`;
export function TelemetryPage(props: TelemetryPageProps) {
const [tab, setTab] = createSignal<Tab>("health");
const [windowSel, setWindowSel] = createSignal("24h");
const [stats, setStats] = createSignal<TelemetryStats | null>(null);
const [issues, setIssues] = createSignal<TelemetryIssue[]>([]);
const [events, setEvents] = createSignal<TelemetryEvent[]>([]);
const [typeFilter, setTypeFilter] = createSignal("");
const [fpFilter, setFpFilter] = createSignal("");
const [metricName, setMetricName] = createSignal("");
const [metricPoints, setMetricPoints] = createSignal<MetricPoint[]>([]);
const [detail, setDetail] = createSignal<TelemetryEvent | null>(null);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [live, setLive] = createSignal(false);
const refresh = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const s = svc(props.demoMode);
const [st, iss, ev] = await Promise.all([
s.getTelemetryStats({ projectId: props.projectID, window: windowSel() as "1h" | "24h" | "7d" | "30d" }),
s.listTelemetryIssues({ projectId: props.projectID }),
s.listTelemetryEvents({
projectId: props.projectID,
type: (typeFilter() || undefined) as "error" | "metric" | "log" | "heartbeat" | "event" | undefined,
fingerprint: fpFilter() || undefined,
limit: 100,
}),
]);
setStats(st);
setIssues(iss.items ?? []);
setEvents(ev.items ?? []);
if (!metricName() && st.metric_names?.length) setMetricName(st.metric_names[0]);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
const refreshMetric = async (name: string) => {
if (!props.projectID || !name) {
setMetricPoints([]);
return;
}
try {
const res = await svc(props.demoMode).getTelemetryMetricSeries({
projectId: props.projectID,
name,
window: windowSel() as "1h" | "24h" | "7d" | "30d",
});
setMetricPoints(res.items ?? []);
} catch {
setMetricPoints([]);
}
};
createEffect(() => {
if (!props.projectID) return;
void refresh();
if (props.demoMode) return;
let es: EventSource | null = null;
let cancelled = false;
void fetchApiToken().then((token) => {
if (cancelled || !token || !props.projectID) return;
const url = `${OpenAPI.BASE}/projects/${props.projectID}/telemetry/stream?token=${encodeURIComponent(token)}`;
es = new EventSource(url);
es.addEventListener("event", (m) => {
setLive(true);
try {
const ev = JSON.parse((m as MessageEvent).data) as TelemetryEvent;
const tf = typeFilter();
const ff = fpFilter();
if ((!tf || ev.type === tf) && (!ff || ev.fingerprint === ff)) {
setEvents((prev) => [ev, ...prev].slice(0, 200));
}
} catch {
/* ignore malformed frames */
}
});
es.onerror = () => setLive(false);
es.onopen = () => setLive(true);
});
onCleanup(() => {
cancelled = true;
es?.close();
});
});
createEffect(() => {
void refreshMetric(metricName());
});
const typeCounts = createMemo(() => {
const totals: Record<string, number> = {};
for (const b of stats()?.series ?? []) {
for (const [t, n] of Object.entries(b.counts ?? {})) {
totals[t] = (totals[t] ?? 0) + n;
}
}
return totals;
});
const maxBucket = createMemo(() =>
Math.max(1, ...(stats()?.series ?? []).map((b) => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0))),
);
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Telemetry</h1>
<p class="page-description">Errors, metrics, logs, and heartbeats across your components.</p>
</div>
<div class="flex items-center gap-2">
<Show when={live()}>
<Badge variant="success">Live</Badge>
</Show>
<Select
value={windowSel()}
onChange={(e) => {
setWindowSel(e.currentTarget.value);
void refresh();
}}
>
<option value="1h">1h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
</Select>
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
<IconRefresh class="w-4 h-4" />
Refresh
</button>
</div>
</div>
<Show when={error()}>
<div class="message message-error">{error()}</div>
</Show>
<div class="flex gap-1 mb-4 flex-wrap">
<For each={["health", "issues", "events", "metrics", "integrate"] as Tab[]}>
{(t) => (
<button
class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${tab() === t ? "bg-surface-2 text-text-1" : "text-text-2 hover:text-text-1"}`}
onClick={() => setTab(t)}
>
{t === "issues" ? `Issues (${issues().length})` : t[0].toUpperCase() + t.slice(1)}
</button>
)}
</For>
</div>
<Show when={tab() === "health"}>
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr))">
<div class="card p-4">
<div class="text-xs text-text-2">Errors</div>
<div class="text-2xl font-semibold">{stats()?.errors ?? 0}</div>
</div>
<div class="card p-4">
<div class="text-xs text-text-2">Events</div>
<div class="text-2xl font-semibold">{stats()?.events ?? 0}</div>
</div>
<div class="card p-4">
<div class="text-xs text-text-2">Metrics</div>
<div class="text-2xl font-semibold">{stats()?.metrics ?? 0}</div>
</div>
</div>
<div class="card card-flush mt-4">
<div class="card-header">
<span class="card-header-title">Activity</span>
</div>
<div class="p-4 flex items-end gap-1 overflow-x-auto" style="min-height: 6rem">
<For each={stats()?.series ?? []}>
{(b) => {
const total = () => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0);
return (
<div
class="flex-1 rounded-sm"
style={{
"min-width": "6px",
height: `${Math.max(4, (total() / maxBucket()) * 80)}px`,
background: (b.counts?.error ?? 0) > 0 ? "var(--error, #e5484d)" : "var(--accent)",
opacity: 0.85,
}}
title={`${new Date(b.ts).toLocaleString()} — ${total()} events`}
/>
);
}}
</For>
</div>
<div class="px-4 pb-3 flex gap-3 flex-wrap text-xs text-text-2">
<For each={Object.entries(typeCounts())}>
{([t, n]) => (
<span>
{t}: {n}
</span>
)}
</For>
</div>
</div>
<div class="card card-flush mt-4">
<div class="card-header">
<span class="card-header-title">Components</span>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Status</th>
<th>Errors 24h</th>
<th>Events 24h</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
<For
each={stats()?.components ?? []}
fallback={
<tr>
<td colspan={6} class="text-center text-text-2 py-6">
No components yet — send an event with a `component` field.
</td>
</tr>
}
>
{(c) => (
<tr>
<td class="font-medium">{c.name}</td>
<td>{c.kind}</td>
<td>
<Badge variant={c.last_status === "up" ? "success" : c.last_status === "down" ? "error" : c.last_status === "degraded" ? "warning" : "neutral"}>
{c.last_status || "unknown"}
</Badge>
</td>
<td>{c.errors_24h}</td>
<td>{c.events_24h}</td>
<td class="text-text-2">{fmtTime(c.last_seen_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Show when={tab() === "issues"}>
<div class="card card-flush">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Error</th>
<th>Component</th>
<th>Count</th>
<th>Severity</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
<For
each={issues()}
fallback={
<tr>
<td colspan={6} class="text-center text-text-2 py-6">
No error groups in the last 30 days.
</td>
</tr>
}
>
{(i) => (
<tr>
<td class="max-w-0 truncate" style="max-width: 24rem" title={i.message}>
{i.message}
</td>
<td>{i.component_name || "—"}</td>
<td>{i.count}</td>
<td>
<Badge variant={sevVariant(i.severity)}>{i.severity}</Badge>
</td>
<td class="text-text-2">{fmtTime(i.last_seen)}</td>
<td>
<button
class="btn btn-secondary btn-sm"
onClick={() => {
setFpFilter(i.fingerprint);
setTab("events");
void refresh();
}}
>
<IconChevronRight class="w-4 h-4" />
Events
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Show when={tab() === "events"}>
<div class="flex gap-2 mb-3 flex-wrap items-center">
<For each={["", "error", "metric", "log", "heartbeat", "event"]}>
{(t) => (
<button
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${typeFilter() === t ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
onClick={() => {
setTypeFilter(t);
void refresh();
}}
>
{t || "all"}
</button>
)}
</For>
<Show when={fpFilter()}>
<button
class="px-3 py-1 rounded-lg text-xs font-medium bg-surface-2 text-text-1"
onClick={() => {
setFpFilter("");
void refresh();
}}
>
fingerprint: {fpFilter().slice(0, 8)}… ✕
</button>
</Show>
</div>
<div class="card card-flush">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Type</th>
<th>Severity</th>
<th>Message</th>
<th>Component</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<For
each={events()}
fallback={
<tr>
<td colspan={5} class="text-center text-text-2 py-6">
No events.
</td>
</tr>
}
>
{(e) => (
<tr class="cursor-pointer" onClick={() => setDetail(e)}>
<td>
<Badge variant={e.type === "error" ? "error" : "neutral"}>{e.type}</Badge>
</td>
<td>
<Badge variant={sevVariant(e.severity)}>{e.severity}</Badge>
</td>
<td class="max-w-0 truncate" style="max-width: 22rem" title={e.message}>
{e.message || <span class="text-text-2">{e.payload?.name ? `${e.payload.name} = ${e.payload.value}` : JSON.stringify(e.payload)}</span>}
</td>
<td>{e.component_name || "—"}</td>
<td class="text-text-2 whitespace-nowrap">{fmtTime(e.ts)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Show when={tab() === "metrics"}>
<div class="flex gap-2 mb-3 flex-wrap">
<For
each={stats()?.metric_names ?? []}
fallback={<span class="text-text-2 text-sm">No metric names seen yet.</span>}
>
{(n) => (
<button
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${metricName() === n ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
onClick={() => setMetricName(n)}
>
{n}
</button>
)}
</For>
</div>
<div class="card card-flush">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Bucket</th>
<th>Avg</th>
<th>p50</th>
<th>p95</th>
<th>Max</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<For
each={metricPoints()}
fallback={
<tr>
<td colspan={6} class="text-center text-text-2 py-6">
Select a metric name.
</td>
</tr>
}
>
{(p) => (
<tr>
<td class="text-text-2 whitespace-nowrap">{fmtTime(p.ts)}</td>
<td>{p.avg?.toFixed(1) ?? "—"}</td>
<td>{p.p50?.toFixed(1) ?? "—"}</td>
<td>{p.p95?.toFixed(1) ?? "—"}</td>
<td>{p.max?.toFixed(1) ?? "—"}</td>
<td>{p.count}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Show when={tab() === "integrate"}>
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr))">
<For each={[{ title: "Web / browser", code: snippetWeb }, { title: "Node / server", code: snippetNode }, { title: "cURL", code: snippetCurl }]}>
{(s) => (
<div class="card card-flush">
<div class="card-header">
<span class="card-header-title">{s.title}</span>
</div>
<pre class="p-4 text-xs overflow-x-auto" style="white-space: pre">{s.code}</pre>
</div>
)}
</For>
</div>
<p class="text-sm text-text-2 mt-4">
Create a project API key under Settings, then pass it as <code>X-Primora-Key</code> to <code>POST /api/v1/ingest</code>. Batches of up to 500 events are accepted.
</p>
</Show>
<Modal
open={detail() !== null}
onClose={() => setDetail(null)}
title={detail()?.message || detail()?.type || "Event"}
>
<Show when={detail()}>
{(e) => (
<div class="flex flex-col gap-3">
<div class="flex gap-2 flex-wrap">
<Badge variant={e().type === "error" ? "error" : "neutral"}>{e().type}</Badge>
<Badge variant={sevVariant(e().severity)}>{e().severity}</Badge>
<Show when={e().component_name}>
<Badge variant="neutral">{e().component_name}</Badge>
</Show>
<Show when={e().fingerprint}>
<Badge variant="neutral">fp {e().fingerprint!.slice(0, 8)}</Badge>
</Show>
</div>
<div class="text-xs text-text-2">{fmtTime(e().ts)}</div>
<pre class="text-xs p-3 rounded-lg bg-surface-2 overflow-x-auto" style="white-space: pre-wrap">
{JSON.stringify(e().payload, null, 2)}
</pre>
</div>
)}
</Show>
</Modal>
</div>
);
}
+1
View File
@@ -5,6 +5,7 @@ export { MembersPage } from "./MembersPage";
export { StoragePage } from "./StoragePage";
export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";