diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..261a63e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "otel-ui-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "otel-ui", "run", "dev", "--", "--port", "5173", "--strictPort"], + "port": 5173 + } + ] +} diff --git a/otel-ui/index.html b/otel-ui/index.html index 87bf682..93b0a7e 100644 --- a/otel-ui/index.html +++ b/otel-ui/index.html @@ -11,6 +11,23 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" /> + + +
diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx index 50660f8..ff80844 100644 --- a/otel-ui/src/App.tsx +++ b/otel-ui/src/App.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef } from "react"; +import { CloudCog } from "lucide-react"; import { useObservabilityData } from "./hooks/useObservabilityData"; -import { Sidebar } from "./components/Sidebar"; -import { TopHeader } from "./components/TopHeader"; +import { IconRail } from "./components/layout/IconRail"; +import { TopBar } from "./components/layout/TopBar"; import { CommandAndQueryBar } from "./components/CommandAndQueryBar"; import { WaveformHistogram } from "./components/WaveformHistogram"; import { EntryVisualization } from "./components/EntryVisualization"; @@ -62,53 +63,59 @@ function AuthCallbackView() { } function App() { - const { isConnected, traces, logs, metrics } = useObservabilityData(); + const { isConnected, traces, logs, metrics, activeTab } = useObservabilityData(); const hasLoadedAnyData = traces.length > 0 || logs.length > 0 || metrics.length > 0; const isCallback = typeof window !== "undefined" && (window.location.pathname === "/auth_callback" || window.location.search.includes("code=")); + // The Issues tab is the redesigned triage overview: it owns its own filter + // bar and cards, so the legacy anomaly banner and volume waveform above it + // would just duplicate that UI in a different visual language. + const isTriageOverview = activeTab === "tab0"; if (isCallback) { return ; } return ( -
- {/* Fixed Left Navigation Sidebar (w-72) */} - +
+ {/* Fixed 72px icon rail */} + - {/* Main Content Area offset by pl-72 */} -
- {/* Fixed Top Header Bar */} - + {/* Main Content Area offset by the rail width */} +
+ {/* Fixed top bar */} + {/* Main Telemetry Console View */} -
-
- {/* Real Anomaly Radar & Filter Bar */} - +
+
+ {!isTriageOverview && ( + <> + {/* Real Anomaly Radar & Filter Bar */} + - {/* Ingest & Incident Waveform Histogram */} - + {/* Ingest & Incident Waveform Histogram */} + + + )} {/* Telemetry Visualizations & Views */} -
+
{isConnected || hasLoadedAnyData ? ( ) : ( -
-
- - cloud_sync - +
+
+
-

+

Establishing telemetry pipeline

-

+

Connecting to OTLP collector. Listening for incoming traces, logs, and metrics.

diff --git a/otel-ui/src/components/FilterBar.tsx b/otel-ui/src/components/FilterBar.tsx new file mode 100644 index 0000000..b620087 --- /dev/null +++ b/otel-ui/src/components/FilterBar.tsx @@ -0,0 +1,165 @@ +import { Plus, X } from "lucide-react" + +export type ChipTone = "critical" | "warn" | "ok" | "neutral" + +export interface FilterChip { + id: string + filterKey: string + value: string + tone?: ChipTone + onRemove: () => void +} + +export interface ScopeOption { + value: string + label: string +} + +export interface AddFilterOption { + value: string + label: string +} + +const toneClass: Record = { + critical: "border-ds-critical/40 bg-ds-critical-bg text-ds-critical", + warn: "border-ds-warn/40 bg-ds-warn-bg text-ds-warn", + ok: "border-ds-ok/40 bg-ds-ok/10 text-ds-ok", + neutral: "border-ds-line-strong bg-ds-surface-2 text-ds-text", +} + +/** + * The filter model shared by every listing screen: a scope segmented + * control, removable chips for each active filter, an add-filter control, + * a match readout that always states what was filtered out, and a clear-all + * action. See the design spec's "filter model" section. + */ +export const FilterBar = ({ + scope, + chips, + addFilterOptions, + onAddFilter, + matchText, + onClearAll, +}: { + scope?: { + label: string + options: ScopeOption[] + value: string + onChange: (value: string) => void + } + chips: FilterChip[] + addFilterOptions?: AddFilterOption[] + onAddFilter?: (value: string) => void + matchText: string + onClearAll?: () => void +}) => { + return ( +
+ {scope && ( +
+ {scope.options.map((opt) => { + const active = opt.value === scope.value + return ( + + ) + })} +
+ )} + +
+ + Filters + + + {chips.map((chip) => ( + + + {chip.filterKey} + + + {chip.value} + + + + ))} + + {chips.length === 0 && ( + + None — every signal in range is shown. + + )} + + {addFilterOptions && addFilterOptions.length > 0 && onAddFilter && ( + + )} + + + + {matchText} + + {chips.length > 0 && onClearAll && ( + + )} +
+
+ ) +} + +export default FilterBar diff --git a/otel-ui/src/components/FrontendVisualizer.tsx b/otel-ui/src/components/FrontendVisualizer.tsx deleted file mode 100644 index 64ef301..0000000 --- a/otel-ui/src/components/FrontendVisualizer.tsx +++ /dev/null @@ -1,1239 +0,0 @@ -import React, { useState, useMemo } from "react"; -import { useObservabilityData } from "../hooks/useObservabilityData"; -import { getAttributeValue, getServiceName } from "../lib/otel-utils"; -import { - Globe, - Monitor, - Smartphone, - AlertCircle, - WifiOff, - Activity, - Gauge, - ArrowRight, - ExternalLink, - Layers, - Search, - Sparkles, - Copy, - Check, - Code, - ChevronRight, - FileCode, - Radio, -} from "lucide-react"; - -// --- Types --- -export interface BreadcrumbItem { - t: number; - category: string; - message: string; -} - -export interface FrontendEvent { - id: string; - timestamp: number; - timeUnixNano: string; - body: string; - severityText: string; - severityNumber: number; - traceId?: string; - service: string; - - // RUM Specific Attributes - isVital: boolean; - vitalName?: string; - vitalValue?: number; - vitalElement?: string; - - url: string; - path: string; - userAgent: string; - viewport: string; - sessionId: string; - - // Errors - isError: boolean; - isHttpFailure: boolean; - exceptionType?: string; - exceptionMessage?: string; - stacktrace?: string; - sourceFile?: string; - sourceLine?: string; - sourceColumn?: string; - - // HTTP / Network - httpMethod?: string; - httpUrl?: string; - httpStatus?: number; - - // Breadcrumbs & Context - breadcrumbs: BreadcrumbItem[]; - rawAttributes: Record; -} - -// Google Core Web Vitals thresholds -interface VitalThreshold { - good: number; - needsImprovement: number; - unit: string; - description: string; - format: (v: number) => string; -} - -const VITAL_THRESHOLDS: Record = { - LCP: { - good: 2500, - needsImprovement: 4000, - unit: "ms", - description: "Largest Contentful Paint (Main content render speed)", - format: (v) => `${(v / 1000).toFixed(2)}s`, - }, - INP: { - good: 200, - needsImprovement: 500, - unit: "ms", - description: "Interaction to Next Paint (UI responsiveness)", - format: (v) => `${Math.round(v)}ms`, - }, - CLS: { - good: 0.1, - needsImprovement: 0.25, - unit: "", - description: "Cumulative Layout Shift (Visual stability)", - format: (v) => (Math.round(v * 1000) / 1000).toFixed(3), - }, - FCP: { - good: 1800, - needsImprovement: 3000, - unit: "ms", - description: "First Contentful Paint (First text or image paint)", - format: (v) => `${(v / 1000).toFixed(2)}s`, - }, - TTFB: { - good: 800, - needsImprovement: 1800, - unit: "ms", - description: "Time to First Byte (Server initial response)", - format: (v) => `${Math.round(v)}ms`, - }, -}; - -function getVitalRating(name: string, value: number): "good" | "needs-improvement" | "poor" { - const t = VITAL_THRESHOLDS[name]; - if (!t) return "good"; - if (value <= t.good) return "good"; - if (value <= t.needsImprovement) return "needs-improvement"; - return "poor"; -} - -function parseUserAgent(ua: string) { - let browser = "Browser"; - let os = "OS"; - let isMobile = /mobile|android|iphone|ipad/i.test(ua); - - if (/chrome|crios/i.test(ua) && !/edg|opr/i.test(ua)) browser = "Chrome"; - else if (/safari/i.test(ua) && !/chrome/i.test(ua)) browser = "Safari"; - else if (/firefox|fxios/i.test(ua)) browser = "Firefox"; - else if (/edg/i.test(ua)) browser = "Edge"; - else if (/opr/i.test(ua)) browser = "Opera"; - - if (/windows/i.test(ua)) os = "Windows"; - else if (/macintosh|mac os x/i.test(ua)) os = "macOS"; - else if (/android/i.test(ua)) os = "Android"; - else if (/iphone|ipad|ipod/i.test(ua)) os = "iOS"; - else if (/linux/i.test(ua)) os = "Linux"; - - return { browser, os, isMobile }; -} - -export const FrontendVisualizer: React.FC = () => { - const { logs, setActiveTab, setSearchQuery: setGlobalSearchQuery } = useObservabilityData(); - - const [activeSubTab, setActiveSubTab] = useState< - "overview" | "crashes" | "network" | "vitals" | "sessions" - >("overview"); - const [selectedEvent, setSelectedEvent] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedVitalFilter, setSelectedVitalFilter] = useState(null); - const [simulating, setSimulating] = useState(false); - const [copiedKey, setCopiedKey] = useState(null); - - const copyToClipboard = (text: string, key: string) => { - navigator.clipboard.writeText(text); - setCopiedKey(key); - setTimeout(() => setCopiedKey(null), 1500); - }; - - // --- Extract All Frontend Records --- - const frontendEvents = useMemo(() => { - const events: FrontendEvent[] = []; - - logs.forEach((log) => { - const resAttrs = log.resource?.attributes ?? []; - const service = getServiceName(resAttrs); - - (log.scopeLogs ?? []).forEach((sl) => { - const scopeName = sl.scope?.name || ""; - - (sl.logRecords ?? []).forEach((r, recordIdx) => { - const attrs = r.attributes ?? []; - const source = getAttributeValue(attrs, "source"); - const vitalName = getAttributeValue(attrs, "rum.vital"); - const browserUrl = getAttributeValue(attrs, "browser.url"); - const sessionId = getAttributeValue(attrs, "session.id"); - const isRumScope = scopeName === "otel-rum"; - - // If this record has frontend RUM traits - if ( - source === "frontend" || - isRumScope || - Boolean(vitalName) || - Boolean(browserUrl) || - Boolean(sessionId) - ) { - let breadcrumbs: BreadcrumbItem[] = []; - const rawBreadcrumbs = getAttributeValue(attrs, "breadcrumbs"); - if (rawBreadcrumbs) { - try { - breadcrumbs = JSON.parse(rawBreadcrumbs); - } catch { - breadcrumbs = []; - } - } - - const rawMap: Record = {}; - attrs.forEach((a) => { - rawMap[a.key] = - a.value?.stringValue ?? - a.value?.intValue ?? - (a.value?.doubleValue !== undefined ? String(a.value.doubleValue) : "") ?? - ""; - }); - - let path = ""; - if (browserUrl) { - try { - path = new URL(browserUrl).pathname; - } catch { - path = browserUrl; - } - } - - const excType = getAttributeValue(attrs, "exception.type"); - const isHttp = excType === "HttpFailure"; - const sevNum = r.severityNumber ?? 0; - const isErr = sevNum >= 17 || excType !== ""; - - const vitalValStr = getAttributeValue(attrs, "rum.value"); - const vitalVal = vitalValStr ? parseFloat(vitalValStr) : undefined; - - const httpStatusStr = getAttributeValue(attrs, "http.response.status_code"); - const httpStatus = httpStatusStr ? parseInt(httpStatusStr, 10) : undefined; - - const id = `${log.id || "fe"}-${recordIdx}-${r.timeUnixNano || Date.now()}`; - - events.push({ - id, - timestamp: r.timeUnixNano ? Number(BigInt(r.timeUnixNano) / 1_000_000n) : Date.now(), - timeUnixNano: r.timeUnixNano || "", - body: r.body?.stringValue || "", - severityText: r.severityText || (isErr ? "ERROR" : "INFO"), - severityNumber: sevNum, - traceId: r.traceId || undefined, - service, - - isVital: Boolean(vitalName), - vitalName: vitalName || undefined, - vitalValue: vitalVal, - vitalElement: getAttributeValue(attrs, "rum.element") || undefined, - - url: browserUrl, - path: path || "/", - userAgent: getAttributeValue(attrs, "browser.userAgent"), - viewport: getAttributeValue(attrs, "browser.viewport"), - sessionId: sessionId || "unknown-session", - - isError: isErr && !isHttp, - isHttpFailure: isHttp, - exceptionType: excType || undefined, - exceptionMessage: - getAttributeValue(attrs, "exception.message") || r.body?.stringValue, - stacktrace: getAttributeValue(attrs, "exception.stacktrace") || undefined, - sourceFile: getAttributeValue(attrs, "source.file") || undefined, - sourceLine: getAttributeValue(attrs, "source.line") || undefined, - sourceColumn: getAttributeValue(attrs, "source.column") || undefined, - - httpMethod: getAttributeValue(attrs, "http.request.method") || undefined, - httpUrl: getAttributeValue(attrs, "url.full") || undefined, - httpStatus, - - breadcrumbs, - rawAttributes: rawMap, - }); - } - }); - }); - }); - - // Sort descending by timestamp - return events.sort((a, b) => b.timestamp - a.timestamp); - }, [logs]); - - // --- Aggregate Web Vitals --- - const vitalsMetrics = useMemo(() => { - const map: Record< - string, - { - values: number[]; - elements: Record; - latest: number; - p75: number; - average: number; - } - > = {}; - - frontendEvents.forEach((e) => { - if (e.isVital && e.vitalName && e.vitalValue !== undefined) { - const name = e.vitalName.toUpperCase(); - if (!map[name]) { - map[name] = { - values: [], - elements: {}, - latest: e.vitalValue, - p75: 0, - average: 0, - }; - } - map[name].values.push(e.vitalValue); - if (e.vitalElement) { - map[name].elements[e.vitalElement] = (map[name].elements[e.vitalElement] || 0) + 1; - } - } - }); - - Object.keys(map).forEach((name) => { - const vals = [...map[name].values].sort((a, b) => a - b); - const idx75 = Math.floor(vals.length * 0.75); - map[name].p75 = vals[Math.min(idx75, vals.length - 1)] || 0; - map[name].average = vals.reduce((sum, v) => sum + v, 0) / vals.length; - }); - - return map; - }, [frontendEvents]); - - // --- Aggregate Sessions --- - const sessions = useMemo(() => { - const sMap: Record< - string, - { - sessionId: string; - eventsCount: number; - errorsCount: number; - urls: Set; - userAgent: string; - lastSeen: number; - firstSeen: number; - } - > = {}; - - frontendEvents.forEach((e) => { - const sId = e.sessionId || "anonymous"; - if (!sMap[sId]) { - sMap[sId] = { - sessionId: sId, - eventsCount: 0, - errorsCount: 0, - urls: new Set(), - userAgent: e.userAgent, - lastSeen: e.timestamp, - firstSeen: e.timestamp, - }; - } - sMap[sId].eventsCount++; - if (e.isError || e.isHttpFailure) sMap[sId].errorsCount++; - if (e.path) sMap[sId].urls.add(e.path); - if (e.timestamp > sMap[sId].lastSeen) sMap[sId].lastSeen = e.timestamp; - if (e.timestamp < sMap[sId].firstSeen) sMap[sId].firstSeen = e.timestamp; - }); - - return Object.values(sMap).sort((a, b) => b.lastSeen - a.lastSeen); - }, [frontendEvents]); - - // --- Filtered Events for Active Subtab --- - const filteredEvents = useMemo(() => { - let result = frontendEvents; - - if (activeSubTab === "crashes") { - result = result.filter((e) => e.isError); - } else if (activeSubTab === "network") { - result = result.filter((e) => e.isHttpFailure); - } else if (activeSubTab === "vitals") { - result = result.filter((e) => e.isVital); - if (selectedVitalFilter) { - result = result.filter((e) => e.vitalName?.toUpperCase() === selectedVitalFilter); - } - } - - if (searchQuery) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (e) => - e.body.toLowerCase().includes(q) || - e.path.toLowerCase().includes(q) || - (e.exceptionType && e.exceptionType.toLowerCase().includes(q)) || - (e.httpUrl && e.httpUrl.toLowerCase().includes(q)) || - (e.sessionId && e.sessionId.toLowerCase().includes(q)) || - (e.traceId && e.traceId.toLowerCase().includes(q)) - ); - } - - return result; - }, [frontendEvents, activeSubTab, selectedVitalFilter, searchQuery]); - - // --- Simulation Generator --- - const handleSimulate = async () => { - setSimulating(true); - try { - const traceId = Array.from({ length: 32 }, () => - Math.floor(Math.random() * 16).toString(16) - ).join(""); - const sessionId = Array.from({ length: 16 }, () => - Math.floor(Math.random() * 16).toString(16) - ).join(""); - const nowMs = Date.now(); - - const sampleBatch = { - resourceLogs: [ - { - resource: { - attributes: [ - { key: "service.name", value: { stringValue: "storefront-web" } }, - { key: "deployment.environment", value: { stringValue: "production" } }, - ], - }, - scopeLogs: [ - { - scope: { name: "otel-rum" }, - logRecords: [ - // Vital 1: FCP - { - timeUnixNano: String((nowMs - 5000) * 1e6), - severityText: "INFO", - severityNumber: 9, - body: { stringValue: "FCP 850" }, - attributes: [ - { key: "source", value: { stringValue: "frontend" } }, - { key: "rum.vital", value: { stringValue: "FCP" } }, - { key: "rum.value", value: { stringValue: "850" } }, - { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, - { key: "session.id", value: { stringValue: sessionId } }, - { key: "browser.viewport", value: { stringValue: "1920x1080" } }, - { - key: "browser.userAgent", - value: { - stringValue: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - }, - }, - ], - }, - // Vital 2: LCP - { - timeUnixNano: String((nowMs - 4000) * 1e6), - severityText: "INFO", - severityNumber: 9, - body: { stringValue: "LCP 1450" }, - attributes: [ - { key: "source", value: { stringValue: "frontend" } }, - { key: "rum.vital", value: { stringValue: "LCP" } }, - { key: "rum.value", value: { stringValue: "1450" } }, - { key: "rum.element", value: { stringValue: "IMG" } }, - { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, - { key: "session.id", value: { stringValue: sessionId } }, - { key: "browser.viewport", value: { stringValue: "1920x1080" } }, - ], - }, - // Vital 3: CLS - { - timeUnixNano: String((nowMs - 3000) * 1e6), - severityText: "INFO", - severityNumber: 9, - body: { stringValue: "CLS 0.045" }, - attributes: [ - { key: "source", value: { stringValue: "frontend" } }, - { key: "rum.vital", value: { stringValue: "CLS" } }, - { key: "rum.value", value: { stringValue: "0.045" } }, - { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, - { key: "session.id", value: { stringValue: sessionId } }, - ], - }, - // Network Failure: Failed fetch with traceparent - { - timeUnixNano: String((nowMs - 2000) * 1e6), - severityText: "ERROR", - severityNumber: 17, - traceId, - body: { stringValue: "POST /api/v1/payment failed with HTTP 500" }, - attributes: [ - { key: "source", value: { stringValue: "frontend" } }, - { key: "exception.type", value: { stringValue: "HttpFailure" } }, - { key: "http.request.method", value: { stringValue: "POST" } }, - { key: "url.full", value: { stringValue: "https://store.acme.com/api/v1/payment" } }, - { key: "http.response.status_code", value: { stringValue: "500" } }, - { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, - { key: "session.id", value: { stringValue: sessionId } }, - ], - }, - // Uncaught Error with user journey breadcrumbs - { - timeUnixNano: String((nowMs - 1000) * 1e6), - severityText: "ERROR", - severityNumber: 17, - traceId, - body: { stringValue: "TypeError: Cannot read properties of undefined (reading 'clientSecret')" }, - attributes: [ - { key: "source", value: { stringValue: "frontend" } }, - { key: "exception.type", value: { stringValue: "TypeError" } }, - { - key: "exception.message", - value: { stringValue: "Cannot read properties of undefined (reading 'clientSecret')" }, - }, - { - key: "exception.stacktrace", - value: { - stringValue: - "TypeError: Cannot read properties of undefined (reading 'clientSecret')\n at PaymentStep (https://store.acme.com/static/js/checkout.min.js:42:115)\n at Object.renderWithHooks (react-dom.production.min.js:14:1287)\n at updateFunctionComponent (react-dom.production.min.js:17:1548)", - }, - }, - { key: "source.file", value: { stringValue: "https://store.acme.com/static/js/checkout.min.js" } }, - { key: "source.line", value: { stringValue: "42" } }, - { key: "source.column", value: { stringValue: "115" } }, - { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, - { key: "session.id", value: { stringValue: sessionId } }, - { key: "browser.viewport", value: { stringValue: "1920x1080" } }, - { - key: "browser.userAgent", - value: { - stringValue: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - }, - }, - { - key: "breadcrumbs", - value: { - stringValue: JSON.stringify([ - { t: nowMs - 15000, category: "navigation", message: "pushState /cart" }, - { t: nowMs - 12000, category: "fetch", message: "GET /api/v1/cart -> 200" }, - { t: nowMs - 9000, category: "navigation", message: "pushState /checkout" }, - { t: nowMs - 5000, category: "fetch", message: "GET /api/v1/shipping-rates -> 200" }, - { t: nowMs - 2000, category: "fetch", message: "POST /api/v1/payment -> 500" }, - { t: nowMs - 1000, category: "console.error", message: "Payment authorization crashed" }, - ]), - }, - }, - ], - }, - ], - }, - ], - }, - ], - }; - - await fetch("/v1/logs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(sampleBatch), - }); - } catch (err) { - console.error("Simulation error:", err); - } finally { - setTimeout(() => setSimulating(false), 500); - } - }; - - const totalErrors = frontendEvents.filter((e) => e.isError).length; - const totalHttpFailures = frontendEvents.filter((e) => e.isHttpFailure).length; - const totalVitals = frontendEvents.filter((e) => e.isVital).length; - - return ( -
- {/* Top Banner & KPI summary */} -
-
-
-
- -
-
-
-

- Frontend & Real User Monitoring (RUM) -

- - otel-rum.js - -
-

- Real user performance, Web Vitals (LCP/INP/CLS), browser exceptions, and breadcrumbs - correlated with backend traces. -

-
-
- -
- - -
-
- - {/* Real KPI Metrics Cards */} -
-
- - - Total Signals - -
- {frontendEvents.length} -
- Live frontend telemetry -
- -
- - - Web Vitals - -
- {totalVitals} -
- LCP / INP / CLS / FCP / TTFB -
- -
- - - Browser Crashes - -
0 ? "text-rose-400" : "text-white" - }`} - > - {totalErrors} -
- Uncaught exceptions -
- -
- - - Network Failures - -
0 ? "text-amber-400" : "text-white" - }`} - > - {totalHttpFailures} -
- Failed fetch / XHR -
- -
- - - Sessions - -
- {sessions.length} -
- Unique client tabs -
-
-
- - {/* Core Web Vitals Dashboard */} -
-
-
- -

- Core Web Vitals Assessment (Google Performance Standards) -

-
- - p75 evaluation metric - -
- -
- {(["LCP", "INP", "CLS", "FCP", "TTFB"] as const).map((key) => { - const threshold = VITAL_THRESHOLDS[key]; - const data = vitalsMetrics[key]; - const hasData = Boolean(data && data.values.length > 0); - const value = hasData ? data.p75 : 0; - const rating = hasData ? getVitalRating(key, value) : "good"; - - const ratingColors = { - good: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10", - "needs-improvement": "text-amber-400 border-amber-500/30 bg-amber-500/10", - poor: "text-rose-400 border-rose-500/30 bg-rose-500/10", - }[rating]; - - const ratingBadge = { - good: "bg-emerald-500/20 text-emerald-300", - "needs-improvement": "bg-amber-500/20 text-amber-300", - poor: "bg-rose-500/20 text-rose-300", - }[rating]; - - const ratingLabel = { - good: "Good", - "needs-improvement": "Needs Improvement", - poor: "Poor", - }[rating]; - - return ( -
{ - setActiveSubTab("vitals"); - setSelectedVitalFilter(key); - }} - className={`cursor-pointer rounded-xl border p-4 transition-all hover:scale-[1.02] ${ - hasData - ? `border-white/10 bg-slate-950/40 hover:border-white/20 ${ratingColors}` - : "border-white/5 bg-slate-950/20 opacity-70" - }`} - > -
- {key} - {hasData ? ( - - {ratingLabel} - - ) : ( - No samples - )} -
- -
- - {hasData ? threshold.format(value) : "—"} - - {hasData && ( - - ({data.values.length} {data.values.length === 1 ? "sample" : "samples"}) - - )} -
- -

- {threshold.description} -

- -
- Target: ≤ {threshold.format(threshold.good)} - {hasData && data.elements && Object.keys(data.elements).length > 0 && ( - - Top tag: {Object.keys(data.elements)[0]} - - )} -
-
- ); - })} -
-
- - {/* Subnavigation Bar & Search */} -
-
- {[ - { id: "overview", label: "All Events", count: frontendEvents.length, icon: Layers }, - { id: "crashes", label: "Crashes & Errors", count: totalErrors, icon: AlertCircle }, - { id: "network", label: "HTTP Failures", count: totalHttpFailures, icon: WifiOff }, - { id: "vitals", label: "Web Vitals Log", count: totalVitals, icon: Activity }, - { id: "sessions", label: "User Sessions", count: sessions.length, icon: Monitor }, - ].map((tab) => { - const Icon = tab.icon; - const isActive = activeSubTab === tab.id; - return ( - - ); - })} -
- -
- - setSearchQuery(e.target.value)} - placeholder="Filter URL, error, session..." - className="w-full rounded-xl border border-white/10 bg-slate-950/60 pl-8 pr-3 py-1.5 text-xs text-white placeholder-slate-500 outline-none focus:border-indigo-500 font-mono transition-colors" - /> -
-
- - {/* Main Content Area */} - {frontendEvents.length === 0 ? ( - /* Empty State with Onboarding */ -
-
- -
-

No Frontend Signals Detected Yet

-

- Add the otel-rum.js script to your web - application to automatically stream Web Vitals, uncaught JS errors, and user journeys. -

- -
- - -
-
- ) : activeSubTab === "sessions" ? ( - /* User Sessions View */ -
-
- - Active Client Browser Sessions ({sessions.length}) - - - Correlated user browser lifetimes - -
- -
- {sessions.map((sess) => { - const ua = parseUserAgent(sess.userAgent); - return ( -
-
-
- {ua.isMobile ? ( - - ) : ( - - )} -
-
-
- {sess.sessionId} - - {ua.browser} on {ua.os} - - {sess.errorsCount > 0 ? ( - - - {sess.errorsCount} {sess.errorsCount === 1 ? "error" : "errors"} - - ) : ( - - Clean - - )} -
-
- Visited: - {Array.from(sess.urls).slice(0, 3).map((u) => ( - - {u} - - ))} - {sess.urls.size > 3 && ( - +{sess.urls.size - 3} more - )} -
-
-
- -
-
-
{sess.eventsCount} events
-
- {new Date(sess.lastSeen).toLocaleTimeString()} -
-
- -
-
- ); - })} -
-
- ) : ( - /* Event Stream View */ -
-
- - Frontend Telemetry Feed ({filteredEvents.length}) - - - Showing {filteredEvents.length} of {frontendEvents.length} events - -
- -
- {filteredEvents.length === 0 ? ( -
- No events match the selected filters or search query. -
- ) : ( - filteredEvents.map((evt) => { - const isSelected = selectedEvent?.id === evt.id; - const ua = parseUserAgent(evt.userAgent); - - return ( -
setSelectedEvent(evt)} - className={`p-4 flex flex-col gap-2.5 cursor-pointer transition-all ${ - isSelected - ? "bg-indigo-500/10 border-l-4 border-l-indigo-500" - : "hover:bg-white/[0.02]" - }`} - > -
-
- {/* Event Category Badge */} - {evt.isVital ? ( - - - {evt.vitalName} - - ) : evt.isHttpFailure ? ( - - - HTTP {evt.httpStatus || "ERR"} - - ) : evt.isError ? ( - - - {evt.exceptionType || "Error"} - - ) : ( - - RUM Info - - )} - - - {evt.isVital - ? `${evt.vitalName}: ${ - VITAL_THRESHOLDS[evt.vitalName || ""]?.format( - evt.vitalValue || 0 - ) || evt.vitalValue - } ${evt.vitalElement ? `(element: <${evt.vitalElement}>)` : ""}` - : evt.body} - - - {evt.path && ( - - {evt.path} - - )} -
- -
- {new Date(evt.timestamp).toLocaleTimeString()} - -
-
- - {/* Metadata chips */} -
- Service: {evt.service} - {ua.browser !== "Browser" && ( - Client: {ua.browser} ({ua.os}) - )} - {evt.viewport && Viewport: {evt.viewport}} - {evt.traceId && ( - - Trace: {evt.traceId.slice(0, 8)}... - - )} - {evt.breadcrumbs.length > 0 && ( - - {evt.breadcrumbs.length} breadcrumbs recorded - - )} -
-
- ); - }) - )} -
-
- )} - - {/* Slide-out Event Detail Inspector Drawer */} - {selectedEvent && ( -
-
-
-
- - {selectedEvent.exceptionType || selectedEvent.vitalName || "Frontend Event"} - -

- {selectedEvent.body} -

-
-

- Occurred at {new Date(selectedEvent.timestamp).toLocaleString()} on service{" "} - {selectedEvent.service} -

-
- - -
- -
- {/* Left Column: Context & Client info */} -
-
- - Client & Navigation Environment - - {selectedEvent.url && ( -
- Target Page URL: - - {selectedEvent.url} - - -
- )} - {selectedEvent.sessionId && ( -
- Browser Session ID: - {selectedEvent.sessionId} -
- )} - {selectedEvent.viewport && ( -
- Viewport Size: - {selectedEvent.viewport} -
- )} - {selectedEvent.userAgent && ( -
- User Agent: - - {selectedEvent.userAgent} - -
- )} -
- - {/* Correlated Backend Trace */} - {selectedEvent.traceId && ( -
-
- - Correlated Backend Trace - - - {selectedEvent.traceId} - -
- -
- )} -
- - {/* Right Column: Breadcrumbs Timeline */} -
-
-
- - User Journey Breadcrumbs ({selectedEvent.breadcrumbs.length}) - - Actions prior to event -
- - {selectedEvent.breadcrumbs.length === 0 ? ( -

- No breadcrumbs attached to this event. -

- ) : ( -
- {selectedEvent.breadcrumbs.map((b, idx) => ( -
- - {b.category} - - {b.message} -
- ))} -
- )} -
-
-
- - {/* Stacktrace if available */} - {selectedEvent.stacktrace && ( -
-
- - - JavaScript Stacktrace - - -
-
-                {selectedEvent.stacktrace}
-              
-
- )} -
- )} -
- ); -}; - -export default FrontendVisualizer; diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx index 5774c5e..6d5867d 100644 --- a/otel-ui/src/components/IssuesVisualizer.tsx +++ b/otel-ui/src/components/IssuesVisualizer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react" +import { useMemo, useState, type ReactNode } from "react" import { Activity, AlertOctagon, @@ -20,21 +20,23 @@ import { import { useObservabilityData } from "../hooks/useObservabilityData" import { authFetch } from "../lib/auth" import { formatRelativeTime } from "../lib/timeUtils" -import { getServicePalette } from "../lib/otel-utils" import { - issueStatusMeta, + issueSeverityTone, issueStatusOf, latestBreadcrumbs, latestStacktrace, sampleContext, - trendPoints, + sparklineSeries, + strongestCorrelate, + type SeverityTone, } from "../lib/issue-utils" +import { FilterBar, type FilterChip } from "./FilterBar" import type { Issue, IssueSample } from "../lib/types" const kindMeta: Record = { - span_error: { label: "Trace Error", Icon: Activity }, - log_error: { label: "Log Error", Icon: ScrollText }, - frontend_error: { label: "Frontend Error", Icon: Globe }, + span_error: { label: "Trace error", Icon: Activity }, + log_error: { label: "Log error", Icon: ScrollText }, + frontend_error: { label: "Frontend error", Icon: Globe }, } type StatusFilter = "all" | "open" | "resolved" | "ignored" @@ -48,66 +50,124 @@ interface HoneypotIP { promoted_until: string } -const statusFilters: { value: StatusFilter; label: string }[] = [ +const statusTabs: { value: StatusFilter; label: string }[] = [ { value: "all", label: "All" }, { value: "open", label: "Open" }, { value: "resolved", label: "Resolved" }, { value: "ignored", label: "Ignored" }, ] -const kindFilters: { value: KindFilter; label: string; Icon: typeof Activity }[] = [ - { value: "all", label: "All kinds", Icon: Bug }, - { value: "span_error", label: "Traces", Icon: Activity }, - { value: "log_error", label: "Logs", Icon: ScrollText }, - { value: "frontend_error", label: "Frontend", Icon: Globe }, +const scopeOptions: { value: KindFilter; label: string }[] = [ + { value: "all", label: "All kinds" }, + { value: "span_error", label: "Traces" }, + { value: "log_error", label: "Logs" }, + { value: "frontend_error", label: "Frontend" }, ] -// Lightweight inline-SVG sparkline of the issue's hourly occurrence trend. -// Deliberately not recharts: issue lists can hold dozens of cards and each -// recharts instance carries meaningful render cost. -const TrendSparkline = ({ issue }: { issue: Issue }) => { - const points = trendPoints(issue, 24) - const max = Math.max(...points.map((p) => p.count), 1) - const width = 96 - const height = 24 +const toneDot: Record = { + critical: "bg-ds-critical", + warn: "bg-ds-warn", + ok: "bg-ds-ok", + muted: "bg-ds-text-3", +} + +const tonePill: Record = { + critical: "bg-ds-critical-bg text-ds-critical", + warn: "bg-ds-warn-bg text-ds-warn", + ok: "bg-ds-ok/10 text-ds-ok", + muted: "bg-ds-fill text-ds-text-3", +} + +const toneLabel: Record = { + critical: "Open", + warn: "Open", + ok: "Resolved", + muted: "Ignored", +} + +const toneLine: Record = { + critical: "border-l-ds-critical", + warn: "border-l-ds-warn", + ok: "border-l-ds-ok", + muted: "border-l-ds-line-strong", +} + +/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */ +const IssueSparkline = ({ issue }: { issue: Issue }) => { + const { points, max, reference, breachIndex } = sparklineSeries(issue, 24) + const width = 100 + const height = 100 const step = width / (points.length - 1) - const path = points - .map((p, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - (p.count / max) * (height - 2) - 1).toFixed(1)}`) - .join(" ") - const gradientId = `sparkFill-${issue.id}` + const toY = (v: number) => height - (v / max) * (height - 8) - 4 + + const observedPoints = points.map((p, i) => `${(i * step).toFixed(1)},${toY(p.count).toFixed(1)}`).join(" ") + + const hasBreach = breachIndex !== null + const breachPath = hasBreach + ? points + .slice(breachIndex) + .map((p, i) => `${i === 0 ? "M" : "L"}${((breachIndex + i) * step).toFixed(1)},${toY(p.count).toFixed(1)}`) + .join(" ") + : "" + const detectX = hasBreach ? breachIndex * step : null + const refY = toY(reference) + const bandHeight = Math.max(height - refY - 4, 6) + + const description = hasBreach + ? `Hourly occurrence count over the last 24 hours, climbing above the period average roughly ${points.length - breachIndex} hours ago and still elevated` + : `Hourly occurrence count over the last 24 hours, staying within the period average` return ( - - - - - - - - - - - - + + + {hasBreach && ( + + )} + {hasBreach && detectX !== null && ( + + )} + ) } - -const StatusBadge = ({ issue }: { issue: Issue }) => { - const status = issueStatusOf(issue) - const meta = issueStatusMeta[status] - return ( - - {meta.label} +const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => ( +
+ {label} + + {value} - ) -} +
+) const StacktraceView = ({ stacktrace }: { stacktrace: string }) => ( -
+  
     {stacktrace}
   
) @@ -117,20 +177,20 @@ const BreadcrumbsView = ({ issue }: { issue: Issue }) => { if (!crumbs.length) return null return (
-

- +

+

-
+
{crumbs.map((crumb, idx) => ( -
- +
+ {new Date(crumb.t).toLocaleTimeString([], { hour12: false })} - + {crumb.category} - {crumb.message.slice(0, 200)} + {crumb.message.slice(0, 200)}
))}
@@ -142,7 +202,7 @@ const SampleContextChips = ({ sample }: { sample: IssueSample }) => { const ctx = sampleContext(sample) if (!ctx.length) return null return ( - + {ctx.join(" · ")} ) @@ -171,14 +231,15 @@ const StatusActions = ({ {actions.map(({ to, label, Icon }) => ( ))} @@ -199,15 +260,12 @@ const IssueCard = ({ const [expanded, setExpanded] = useState(false) const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug } - const isFrontend = issue.kind === "frontend_error" - const badgeGradient = isFrontend - ? "from-violet-500 via-sky-500 to-cyan-400" - : "from-rose-500 via-red-500 to-orange-400" - const palette = getServicePalette(issue.service) + const tone = issueSeverityTone(issue) const latestSample = issue.samples[issue.samples.length - 1] const stacktrace = latestStacktrace(issue) const hasMappedStacktrace = issue.samples.some((sample) => Boolean(sample.resolvedStacktrace)) const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus) + const correlate = strongestCorrelate(issue) const viewTrace = (traceId: string, spanId?: string) => { setActiveTraceId(traceId) @@ -215,60 +273,111 @@ const IssueCard = ({ setActiveTab("tab1") } + const metaParts = [`service=${issue.service}`] + if (contextSample?.httpMethod && contextSample?.httpRoute) { + metaParts.push(`route=${contextSample.httpMethod} ${contextSample.httpRoute}`) + } else if (contextSample?.httpRoute) { + metaParts.push(`route=${contextSample.httpRoute}`) + } + if (issue.affectedServices.length > 1) { + metaParts.push(`affects=${issue.affectedServices.length} services`) + } + return ( -
-
+
+
+ -
setExpanded((prev) => !prev)} - className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4" - > - - - {meta.label} - +
+ {latestSample?.traceId && ( + + )} + + +
+
- - {issue.service} - +
+ +
- - {issue.title} - - - - - - ×{issue.count} - - - - - - last seen {formatRelativeTime(issue.lastSeen / 1e6)} - - - {expanded ? : } +
+ + + +
+ Strongest correlate + {correlate ? ( + + {correlate.key}={correlate.value}{" "} + + {Math.round(correlate.share * 100)}% of {correlate.total} occurrences + + + ) : ( + NO SIGNAL + )} +
{expanded && ( -
+
-
- First seen {formatRelativeTime(issue.firstSeen / 1e6)} - {issue.affectedServices.length > 1 && ( - Affects: {issue.affectedServices.join(", ")} - )} +
{issue.status === "resolved" && issue.resolvedAt ? ( Resolved {formatRelativeTime(issue.resolvedAt / 1e6)} ) : null} + {issue.affectedServices.length > 1 && ( + Affects: {issue.affectedServices.join(", ")} + )}
-
{stacktrace && (
-

+

{hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}

@@ -277,10 +386,10 @@ const IssueCard = ({ -
+
{(contextSample?.userAgent || latestSample?.userAgent) && ( - - + + )} @@ -293,16 +402,16 @@ const IssueCard = ({
-

+

Recent occurrences ({issue.samples.length})

{[...issue.samples].reverse().map((sample, idx) => (
- + {new Date(sample.timestamp / 1e6).toLocaleTimeString([], { hour12: false, hour: "2-digit", @@ -311,51 +420,124 @@ const IssueCard = ({ })} - {sample.message} + {sample.message} {sample.url && ( e.stopPropagation()} - className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500/10 border border-violet-400/20 px-2.5 py-0.5 text-[10px] font-bold text-violet-300 hover:bg-violet-500/20 hover:border-violet-400/30 transition-all max-w-[220px] truncate" + className="flex max-w-[220px] shrink-0 items-center gap-1 truncate rounded-full border border-ds-accent/30 bg-ds-accent-bg px-2.5 py-0.5 text-[10.5px] font-bold text-ds-accent" title={sample.url} > - + )} {sample.traceId && ( )}
))}
- - {latestSample?.traceId && ( -
- -
- )}
)} -
+
+ ) +} + +const ServiceDeviationPanel = () => { + const { uniqueServices, serviceHealth } = useObservabilityData() + + const rows = useMemo(() => { + return uniqueServices + .map((name) => { + const health = serviceHealth[name] + const total = health?.totalSignals ?? 0 + const errors = health?.errorCount ?? 0 + return { name, total, errors, rate: total > 0 ? errors / total : 0 } + }) + .sort((a, b) => b.errors - a.errors || b.rate - a.rate) + .slice(0, 8) + }, [uniqueServices, serviceHealth]) + + return ( +
+
+

Services by deviation

+ + {uniqueServices.length} total +
+ {rows.length === 0 ? ( +

Listening for service signals…

+ ) : ( +
    + {rows.map((row) => ( +
  • + + {row.name} + +
  • + ))} +
+ )} +
+ ) +} + +const IssueStatusPanel = ({ counts }: { counts: Record }) => { + const total = counts.all || 1 + const segments: { key: StatusFilter; tone: SeverityTone }[] = [ + { key: "open", tone: "critical" }, + { key: "resolved", tone: "ok" }, + { key: "ignored", tone: "muted" }, + ] + return ( +
+

Issue status

+ +
    + {segments.map((seg) => ( +
  • +
  • + ))} +
+
) } @@ -376,7 +558,8 @@ export const IssuesVisualizer = ({ onStatusChange, statusBusy, }: IssuesVisualizerProps) => { - const { selectedServices, searchQuery } = useObservabilityData() + const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } = + useObservabilityData() const [statusFilter, setStatusFilter] = useState("all") const [kindFilter, setKindFilter] = useState("all") const [excludeSpamIPs, setExcludeSpamIPs] = useState(false) @@ -402,7 +585,7 @@ export const IssuesVisualizer = ({ const spamIPSet = useMemo(() => new Set(spamIPs.map((entry) => entry.ip)), [spamIPs]) const statusCounts = useMemo(() => { - const counts = { all: issues.length, open: 0, resolved: 0, ignored: 0 } + const counts: Record = { all: issues.length, open: 0, resolved: 0, ignored: 0 } issues.forEach((issue) => { counts[issueStatusOf(issue)] += 1 }) @@ -440,160 +623,250 @@ export const IssuesVisualizer = ({ }) }, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet]) + const { primaryIssues, secondaryIssues } = useMemo(() => { + const sorted = [...filteredIssues].sort((a, b) => b.count - a.count) + if (statusFilter !== "all") return { primaryIssues: sorted, secondaryIssues: [] as Issue[] } + return { + primaryIssues: sorted.filter((i) => issueStatusOf(i) === "open"), + secondaryIssues: sorted.filter((i) => issueStatusOf(i) !== "open"), + } + }, [filteredIssues, statusFilter]) + + const [showSecondary, setShowSecondary] = useState(false) + + const activeFilterCount = + selectedServices.size + (statusFilter !== "all" ? 1 : 0) + (excludeSpamIPs ? 1 : 0) + (searchQuery ? 1 : 0) + + const chips: FilterChip[] = [ + ...Array.from(selectedServices).map((service) => ({ + id: `service-${service}`, + filterKey: "service", + value: service, + tone: "neutral" as const, + onRemove: () => toggleService(service), + })), + ...(statusFilter !== "all" + ? [ + { + id: "status", + filterKey: "status", + value: statusFilter, + tone: statusFilter === "resolved" ? ("ok" as const) : ("neutral" as const), + onRemove: () => setStatusFilter("all"), + }, + ] + : []), + ...(excludeSpamIPs + ? [ + { + id: "spam-ips", + filterKey: "spam-ips", + value: "hidden", + tone: "neutral" as const, + onRemove: () => setExcludeSpamIPs(false), + }, + ] + : []), + ...(searchQuery + ? [ + { + id: "query", + filterKey: "query", + value: searchQuery, + tone: "neutral" as const, + onRemove: () => setSearchQuery(""), + }, + ] + : []), + ] + + const addFilterOptions = uniqueServices + .filter((s) => !selectedServices.has(s)) + .map((s) => ({ value: s, label: s })) + + const clearAll = () => { + resetFilters() + setSearchQuery("") + setStatusFilter("all") + setExcludeSpamIPs(false) + } + if (loading && issues.length === 0) { return ( -
- - Loading issues... +
+
) } return ( -
-
-
- - - Deduplicated Issues - +
+ setKindFilter(v as KindFilter), + }} + chips={chips} + addFilterOptions={addFilterOptions} + onAddFilter={(v) => toggleService(v)} + matchText={`Matching ${filteredIssues.length} of ${issues.length} issues · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`} + onClearAll={activeFilterCount > 0 ? clearAll : undefined} + /> - {/* Status filter chips */} -
- {statusFilters.map(({ value, label }) => ( - - ))} -
- - - - {/* Kind filter chips */} -
- {kindFilters.map(({ value, label, Icon }) => ( - - ))} -
- - - {filteredIssues.length} shown - -
- -
- {error && Failed to load: {error}} - - -
-
- - {showSpamIPs && ( -
-
-
-
Promoted honeypot IPs
-
Used by the ingress tarpit synchronizer.
+
+
+
+

+ {statusFilter === "all" ? "Needs attention" : `${statusTabs.find((s) => s.value === statusFilter)?.label} issues`} +

+ + {statusFilter === "all" ? `${primaryIssues.length} open` : filteredIssues.length} + + +
+ {statusTabs.map((t) => ( + + ))}
+
- {spamIPsLoading &&
Loading…
} - {spamIPsError &&
Failed to load: {spamIPsError}
} - {!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && ( -
No promoted IPs.
+ + {error && ( +

Failed to load: {error}

)} - {spamIPs.length > 0 && ( -
- {spamIPs.map((entry) => ( -
- {entry.ip} - ×{entry.count} + + {showSpamIPs && ( +
+
+
+
Promoted honeypot IPs
+
Used by the ingress tarpit synchronizer.
+ +
+ {spamIPsLoading &&
Loading…
} + {spamIPsError &&
Failed to load: {spamIPsError}
} + {!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && ( +
No promoted IPs.
+ )} + {spamIPs.length > 0 && ( +
+ {spamIPs.map((entry) => ( +
+ {entry.ip} + ×{entry.count} +
+ ))} +
+ )} +
+ )} + + {!showSpamIPs && ( + + )} + + {primaryIssues.length === 0 ? ( +
+
+ ) : ( +
+ {primaryIssues.map((issue) => ( + ))}
)} -
- )} - {filteredIssues.length === 0 ? ( -
-
- -
-

- {issues.length === 0 ? "No issues detected" : "No issues match your filters"} -

-

- {issues.length === 0 - ? "Error-status spans and error/fatal-severity logs will show up here as they arrive." - : "Try adjusting your filters or search query."} -

+ {secondaryIssues.length > 0 && ( + <> + + {showSecondary && ( +
+ {secondaryIssues.map((issue) => ( + + ))} +
+ )} + + )}
- ) : ( -
- {filteredIssues.map((issue) => ( - - ))} -
- )} + + +
) } diff --git a/otel-ui/src/components/RumSessionsVisualizer.tsx b/otel-ui/src/components/RumSessionsVisualizer.tsx new file mode 100644 index 0000000..4a32aeb --- /dev/null +++ b/otel-ui/src/components/RumSessionsVisualizer.tsx @@ -0,0 +1,488 @@ +import { useMemo, useState } from "react" +import { + AlertOctagon, + Copy, + ExternalLink, + Laptop, + Monitor, + Radio, + Smartphone, +} from "lucide-react" +import { useObservabilityData } from "../hooks/useObservabilityData" +import { formatRelativeTime } from "../lib/timeUtils" +import { + VITAL_THRESHOLDS, + aggregateRum, + buildRumSessions, + getVitalRating, + type RumSession, + type RumTimelineEntry, + type VitalRating, +} from "../lib/rum-utils" +import { FilterBar, type FilterChip } from "./FilterBar" + +type DeviceFilter = "all" | "mobile" | "desktop" +type SortMode = "worst" | "recent" + +const vitalTone: Record = { + good: "text-ds-ok", + "needs-improvement": "text-ds-warn", + poor: "text-ds-critical", +} + +function formatElapsed(msFromStart: number): string { + const totalSeconds = Math.max(0, Math.round(msFromStart / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` +} + +const categoryLabel: Record = { + "session-start": "Session started", + navigation: "Navigation", + fetch: "Fetch", + xhr: "XHR", + "console.error": "Console error", + "js-error": "JS error", + "http-failure": "Request failed", +} + +const StatTile = ({ + label, + value, + detail, + tone, +}: { + label: string + value: string + detail: string + tone?: "critical" | "warn" | "ok" +}) => ( +
+ {label} + + {value} + + {detail} +
+) + +const DeviceIcon = ({ isMobile }: { isMobile: boolean }) => + isMobile ? ( +