mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
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:
1 parent
6507eee606
commit
8cfddf9184
60 files changed
+3694
-30
No files matched your search
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" /></>));
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user