redesgin
This commit is contained in:
1 parent
21f04d696b
commit
5792e9c037
16 files changed
+2101
-2109
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
<!-- Stackship design-spec typefaces: Bricolage Grotesque for headings and
|
||||
metric numbers, Instrument Sans for UI text, IBM Plex Mono for
|
||||
anything a person would copy or grep. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Instrument+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script>
|
||||
// Applied synchronously, before first paint, so the redesigned screens
|
||||
// (icon rail, top bar, filter bar, triage overview) don't flash the
|
||||
// wrong theme. Screens still on the legacy palette ignore this
|
||||
// attribute entirely - they have no light variant yet.
|
||||
try {
|
||||
var storedTheme = localStorage.getItem("otel.ui.theme");
|
||||
if (storedTheme === "light") document.documentElement.dataset.theme = "light";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface min-h-screen antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
+31
-24
@@ -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 <AuthCallbackView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background font-body text-on-surface min-h-screen antialiased selection:bg-primary-container selection:text-on-primary-container">
|
||||
{/* Fixed Left Navigation Sidebar (w-72) */}
|
||||
<Sidebar />
|
||||
<div className="bg-ds-bg font-body text-ds-text min-h-screen antialiased selection:bg-ds-accent-bg selection:text-ds-accent">
|
||||
{/* Fixed 72px icon rail */}
|
||||
<IconRail />
|
||||
|
||||
{/* Main Content Area offset by pl-72 */}
|
||||
<div className="pl-72">
|
||||
{/* Fixed Top Header Bar */}
|
||||
<TopHeader />
|
||||
{/* Main Content Area offset by the rail width */}
|
||||
<div className="pl-[72px]">
|
||||
{/* Fixed top bar */}
|
||||
<TopBar />
|
||||
|
||||
{/* Main Telemetry Console View */}
|
||||
<main className="relative pt-18 w-full px-6 bg-background min-h-screen pb-12">
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Real Anomaly Radar & Filter Bar */}
|
||||
<CommandAndQueryBar />
|
||||
<main className="relative w-full bg-ds-bg pb-12 pt-[58px]">
|
||||
<div className="flex w-full flex-col px-6">
|
||||
{!isTriageOverview && (
|
||||
<>
|
||||
{/* Real Anomaly Radar & Filter Bar */}
|
||||
<CommandAndQueryBar />
|
||||
|
||||
{/* Ingest & Incident Waveform Histogram */}
|
||||
<WaveformHistogram />
|
||||
{/* Ingest & Incident Waveform Histogram */}
|
||||
<WaveformHistogram />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Telemetry Visualizations & Views */}
|
||||
<section className="relative flex-1 flex flex-col min-h-0">
|
||||
<section className="relative flex min-h-0 flex-1 flex-col">
|
||||
{isConnected || hasLoadedAnyData ? (
|
||||
<EntryVisualization />
|
||||
) : (
|
||||
<div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-4 text-center rounded-2xl border border-dashed border-surface-container-high p-12 bg-surface-container-lowest">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-container-high text-primary-container shadow-lg animate-pulse">
|
||||
<span className="material-symbols-outlined text-[32px]">
|
||||
cloud_sync
|
||||
</span>
|
||||
<div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-4 rounded-[14px] border border-dashed border-ds-line bg-ds-surface p-12 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-ds-accent-bg text-ds-accent">
|
||||
<CloudCog className="h-8 w-8 animate-pulse" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-on-surface">
|
||||
<p className="text-base font-semibold text-ds-text">
|
||||
Establishing telemetry pipeline
|
||||
</p>
|
||||
<p className="text-xs text-outline mt-1 max-w-sm mx-auto">
|
||||
<p className="mx-auto mt-1 max-w-sm text-xs text-ds-text-3">
|
||||
Connecting to OTLP collector. Listening for incoming traces, logs, and metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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<ChipTone, string> = {
|
||||
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 (
|
||||
<section
|
||||
aria-label="Filters"
|
||||
className="flex flex-col gap-3 border-b border-ds-line bg-ds-bg px-1 pb-4 pt-1 font-[family-name:var(--font-ui)]"
|
||||
>
|
||||
{scope && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={scope.label}
|
||||
className="inline-flex w-fit items-center gap-1 rounded-[10px] border border-ds-line bg-ds-surface p-1"
|
||||
>
|
||||
{scope.options.map((opt) => {
|
||||
const active = opt.value === scope.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => scope.onChange(opt.value)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex h-8 items-center rounded-[7px] px-3.5 text-[13.5px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-ds-accent text-ds-on-accent"
|
||||
: "text-ds-text-2 hover:text-ds-text"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
|
||||
Filters
|
||||
</span>
|
||||
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.id}
|
||||
className={`inline-flex h-9 shrink-0 items-center gap-2 whitespace-nowrap rounded-[9px] border py-0 pl-3 pr-1 ${toneClass[chip.tone ?? "neutral"]}`}
|
||||
>
|
||||
<span className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">
|
||||
{chip.filterKey}
|
||||
</span>
|
||||
<span className="max-w-[220px] truncate font-[family-name:var(--font-data)] text-[12.5px] font-medium">
|
||||
{chip.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
aria-label={`Remove filter ${chip.filterKey}=${chip.value}`}
|
||||
className="inline-flex h-[26px] w-[26px] items-center justify-center rounded-[7px] text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{chips.length === 0 && (
|
||||
<span className="text-[13px] text-ds-text-2">
|
||||
None — every signal in range is shown.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{addFilterOptions && addFilterOptions.length > 0 && onAddFilter && (
|
||||
<label className="relative inline-flex h-9 items-center rounded-[9px] border border-dashed border-ds-line-strong px-3 text-[13px] text-ds-text-2 hover:text-ds-text">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="pointer-events-none">Add filter</span>
|
||||
<select
|
||||
aria-label="Add a filter"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onAddFilter(e.target.value)
|
||||
e.target.value = ""
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Add filter
|
||||
</option>
|
||||
{addFilterOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<span className="flex-grow" />
|
||||
|
||||
<span className="text-[13px] text-ds-text-2">{matchText}</span>
|
||||
|
||||
{chips.length > 0 && onClearAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-3 text-[13px] font-medium text-ds-text hover:bg-ds-surface-2"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<string, { label: string; Icon: typeof Activity }> = {
|
||||
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<SeverityTone, string> = {
|
||||
critical: "bg-ds-critical",
|
||||
warn: "bg-ds-warn",
|
||||
ok: "bg-ds-ok",
|
||||
muted: "bg-ds-text-3",
|
||||
}
|
||||
|
||||
const tonePill: Record<SeverityTone, string> = {
|
||||
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<SeverityTone, string> = {
|
||||
critical: "Open",
|
||||
warn: "Open",
|
||||
ok: "Resolved",
|
||||
muted: "Ignored",
|
||||
}
|
||||
|
||||
const toneLine: Record<SeverityTone, string> = {
|
||||
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 (
|
||||
<span
|
||||
className="inline-flex shrink-0 flex-col items-end gap-0.5"
|
||||
title="Occurrences per hour (last 24h)"
|
||||
<svg
|
||||
width="100%"
|
||||
height="56"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={description}
|
||||
>
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<path d={`${path} L${width},${height} L0,${height} Z`} fill={`url(#${gradientId})`} opacity="0.35" />
|
||||
<path d={path} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" className="text-rose-400/80" />
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" className="text-rose-400" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" className="text-rose-400" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</span>
|
||||
<rect x={0} y={refY} width={width} height={bandHeight} fill="var(--ds-fill)" />
|
||||
<polyline
|
||||
points={observedPoints}
|
||||
fill="none"
|
||||
stroke="var(--ds-text-3)"
|
||||
strokeWidth="1.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{hasBreach && (
|
||||
<path d={breachPath} fill="none" stroke="var(--ds-critical)" strokeWidth="2.2" vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
{hasBreach && detectX !== null && (
|
||||
<line
|
||||
x1={detectX}
|
||||
y1={0}
|
||||
x2={detectX}
|
||||
y2={height}
|
||||
stroke="var(--ds-line-strong)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const StatusBadge = ({ issue }: { issue: Issue }) => {
|
||||
const status = issueStatusOf(issue)
|
||||
const meta = issueStatusMeta[status]
|
||||
return (
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${meta.className}`}>
|
||||
{meta.label}
|
||||
const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[18px] font-bold ${
|
||||
tone ? (tone === "critical" ? "text-ds-critical" : tone === "warn" ? "text-ds-warn" : tone === "ok" ? "text-ds-ok" : "text-ds-text") : "text-ds-text"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
const StacktraceView = ({ stacktrace }: { stacktrace: string }) => (
|
||||
<pre className="max-h-64 overflow-auto rounded-xl border border-white/5 bg-black/60 p-3 font-mono text-[10px] leading-relaxed text-slate-300 whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-64 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2 whitespace-pre-wrap break-all">
|
||||
{stacktrace}
|
||||
</pre>
|
||||
)
|
||||
@@ -117,20 +177,20 @@ const BreadcrumbsView = ({ issue }: { issue: Issue }) => {
|
||||
if (!crumbs.length) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<p className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Breadcrumbs before error
|
||||
</p>
|
||||
<div className="max-h-48 space-y-1 overflow-auto rounded-xl border border-white/5 bg-black/40 p-3">
|
||||
<div className="max-h-48 space-y-1 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3">
|
||||
{crumbs.map((crumb, idx) => (
|
||||
<div key={`${crumb.t}-${idx}`} className="flex items-baseline gap-2 font-mono text-[10px]">
|
||||
<span className="shrink-0 text-slate-600">
|
||||
<div key={`${crumb.t}-${idx}`} className="flex items-baseline gap-2 font-[family-name:var(--font-data)] text-[11px]">
|
||||
<span className="shrink-0 text-ds-text-3">
|
||||
{new Date(crumb.t).toLocaleTimeString([], { hour12: false })}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-sky-400/80">
|
||||
<span className="shrink-0 rounded bg-ds-fill px-1.5 py-0.5 text-[10px] font-bold uppercase text-ds-text-2">
|
||||
{crumb.category}
|
||||
</span>
|
||||
<span className="min-w-0 break-all text-slate-300">{crumb.message.slice(0, 200)}</span>
|
||||
<span className="min-w-0 break-all text-ds-text-2">{crumb.message.slice(0, 200)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -142,7 +202,7 @@ const SampleContextChips = ({ sample }: { sample: IssueSample }) => {
|
||||
const ctx = sampleContext(sample)
|
||||
if (!ctx.length) return null
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 font-mono text-[10px] font-bold text-amber-300">
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-ds-warn/30 bg-ds-warn-bg px-2 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] font-bold text-ds-warn">
|
||||
{ctx.join(" · ")}
|
||||
</span>
|
||||
)
|
||||
@@ -171,14 +231,15 @@ const StatusActions = ({
|
||||
{actions.map(({ to, label, Icon }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStatusChange(issue, to)
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-all hover:border-white/20 hover:text-white disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 transition-colors hover:bg-ds-surface-2 hover:text-ds-text disabled:opacity-50"
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
@@ -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 (
|
||||
<div className="group relative flex flex-col overflow-hidden rounded-xl border border-rose-500/10 bg-slate-950/40 transition-all hover:border-rose-500/25 hover:bg-slate-900/50">
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b ${badgeGradient} opacity-50 transition-opacity group-hover:opacity-100`} />
|
||||
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
|
||||
<div className="flex w-full flex-wrap items-start gap-4 px-5 pt-[18px] pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} details for ${issue.title}`}
|
||||
className="flex min-w-0 flex-1 flex-col gap-1.5 text-left"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className={`inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||||
{toneLabel[tone]}
|
||||
</span>
|
||||
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[12.5px] text-ds-text-3">
|
||||
Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "}
|
||||
{formatRelativeTime(issue.lastSeen / 1e6)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text break-all">
|
||||
{issue.title}
|
||||
</h3>
|
||||
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{metaParts.join(" · ")}</p>
|
||||
</button>
|
||||
|
||||
<div
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4"
|
||||
>
|
||||
<span className={`inline-flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r ${badgeGradient} text-slate-950`}>
|
||||
<meta.Icon className="h-3 w-3" />
|
||||
{meta.label}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{latestSample?.traceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
|
||||
className="inline-flex h-9 items-center rounded-[9px] bg-ds-accent px-3.5 text-[13px] font-semibold text-ds-on-accent"
|
||||
>
|
||||
View trace
|
||||
</button>
|
||||
)}
|
||||
<StatusActions issue={issue} onStatusChange={onStatusChange} busy={statusBusy} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} details for ${issue.title}`}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${palette.bg} ${palette.text} ${palette.border} border`}>
|
||||
{issue.service}
|
||||
</span>
|
||||
<div className="px-5">
|
||||
<IssueSparkline issue={issue} />
|
||||
</div>
|
||||
|
||||
<span className="flex-1 min-w-[240px] break-all font-mono text-[12px] text-slate-200">
|
||||
{issue.title}
|
||||
</span>
|
||||
|
||||
<TrendSparkline issue={issue} />
|
||||
|
||||
<span className="shrink-0 rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold text-rose-300">
|
||||
×{issue.count}
|
||||
</span>
|
||||
|
||||
<StatusBadge issue={issue} />
|
||||
|
||||
<span className="shrink-0 text-[10px] text-slate-500 whitespace-nowrap">
|
||||
last seen {formatRelativeTime(issue.lastSeen / 1e6)}
|
||||
</span>
|
||||
|
||||
{expanded ? <ChevronUp className="h-3 w-3 text-slate-500" /> : <ChevronDown className="h-3 w-3 text-slate-500" />}
|
||||
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
|
||||
<StatCell label="Occurrences" value={`×${issue.count}`} />
|
||||
<StatCell label="Affected services" value={issue.affectedServices.length || 1} />
|
||||
<StatCell label="First seen" value={formatRelativeTime(issue.firstSeen / 1e6)} />
|
||||
<div className="flex flex-[1.4] flex-col gap-1">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Strongest correlate</span>
|
||||
{correlate ? (
|
||||
<span className="font-[family-name:var(--font-data)] text-[13px] font-medium text-ds-text">
|
||||
{correlate.key}={correlate.value}{" "}
|
||||
<span className="font-[family-name:var(--font-ui)] text-[12px] text-ds-text-3">
|
||||
{Math.round(correlate.share * 100)}% of {correlate.total} occurrences
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-[family-name:var(--font-data)] text-[13px] font-medium text-ds-text-3">NO SIGNAL</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-4 border-t border-white/5 bg-black/40 p-4 text-[12px]">
|
||||
<div className="flex flex-col gap-4 border-t border-ds-line bg-ds-bg px-5 py-4 text-[12.5px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4 text-[10px] text-slate-500">
|
||||
<span>First seen {formatRelativeTime(issue.firstSeen / 1e6)}</span>
|
||||
{issue.affectedServices.length > 1 && (
|
||||
<span>Affects: {issue.affectedServices.join(", ")}</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px] text-ds-text-3">
|
||||
{issue.status === "resolved" && issue.resolvedAt ? (
|
||||
<span>Resolved {formatRelativeTime(issue.resolvedAt / 1e6)}</span>
|
||||
) : null}
|
||||
{issue.affectedServices.length > 1 && (
|
||||
<span>Affects: {issue.affectedServices.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusActions issue={issue} onStatusChange={onStatusChange} busy={statusBusy} />
|
||||
</div>
|
||||
|
||||
{stacktrace && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||
{hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}
|
||||
</p>
|
||||
<StacktraceView stacktrace={stacktrace} />
|
||||
@@ -277,10 +386,10 @@ const IssueCard = ({
|
||||
|
||||
<BreadcrumbsView issue={issue} />
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-[10px] text-slate-500">
|
||||
<div className="flex flex-wrap gap-4 text-[11px] text-ds-text-3">
|
||||
{(contextSample?.userAgent || latestSample?.userAgent) && (
|
||||
<span className="inline-flex items-center gap-1.5 max-w-[360px] truncate" title={contextSample?.userAgent || latestSample?.userAgent}>
|
||||
<MonitorSmartphone className="h-3 w-3 shrink-0" />
|
||||
<span className="inline-flex max-w-[360px] items-center gap-1.5 truncate" title={contextSample?.userAgent || latestSample?.userAgent}>
|
||||
<MonitorSmartphone className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)}
|
||||
</span>
|
||||
)}
|
||||
@@ -293,16 +402,16 @@ const IssueCard = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
|
||||
Recent occurrences ({issue.samples.length})
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[...issue.samples].reverse().map((sample, idx) => (
|
||||
<div
|
||||
key={`${sample.timestamp}-${idx}`}
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-2.5 py-1.5"
|
||||
className="flex flex-wrap items-center gap-3 rounded-[9px] border border-ds-line bg-ds-surface px-2.5 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 text-slate-500 font-mono text-[10px]">
|
||||
<span className="shrink-0 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||||
{new Date(sample.timestamp / 1e6).toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
@@ -311,51 +420,124 @@ const IssueCard = ({
|
||||
})}
|
||||
</span>
|
||||
<SampleContextChips sample={sample} />
|
||||
<span className="flex-1 min-w-[160px] break-all text-slate-300">{sample.message}</span>
|
||||
<span className="min-w-[160px] flex-1 break-all text-ds-text-2">{sample.message}</span>
|
||||
{sample.url && (
|
||||
<a
|
||||
href={sample.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => 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}
|
||||
>
|
||||
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
||||
<ExternalLink className="h-2.5 w-2.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{sample.url.replace(/^https?:\/\//, "")}</span>
|
||||
</a>
|
||||
)}
|
||||
{sample.traceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
viewTrace(sample.traceId!, sample.spanId)
|
||||
}}
|
||||
className="group/trace flex shrink-0 items-center gap-1.5 rounded-full bg-sky-500/10 border border-sky-400/20 px-2.5 py-0.5 text-[10px] font-bold text-sky-400 hover:bg-sky-500/20 hover:border-sky-400/30 transition-all"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full border border-ds-line-strong px-2.5 py-0.5 text-[10.5px] font-bold text-ds-text-2 hover:text-ds-text"
|
||||
>
|
||||
<Activity className="h-2.5 w-2.5 group-hover/trace:animate-pulse" />
|
||||
TRACE:{sample.traceId.slice(0, 6)}
|
||||
<Activity className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
trace:{sample.traceId.slice(0, 6)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestSample?.traceId && (
|
||||
<div className="mt-1 pt-4 border-t border-white/5 flex justify-end">
|
||||
<button
|
||||
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-sky-500 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-950 transition-all hover:bg-sky-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-sky-500/20"
|
||||
>
|
||||
<Activity className="h-3 w-3" />
|
||||
View Latest Trace
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">Services by deviation</h2>
|
||||
<span className="flex-grow" />
|
||||
<span className="text-[11.5px] text-ds-text-3">{uniqueServices.length} total</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="py-3 text-[12px] italic text-ds-text-3">Listening for service signals…</p>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{rows.map((row) => (
|
||||
<li key={row.name} className="flex items-center gap-3 border-b border-ds-fill py-2.5 last:border-b-0">
|
||||
<span className="flex-1 truncate font-[family-name:var(--font-data)] text-[12.5px] text-ds-text">
|
||||
{row.name}
|
||||
</span>
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-ds-fill" aria-hidden="true">
|
||||
<div
|
||||
className={`h-full rounded-full ${row.errors > 0 ? "bg-ds-critical" : "bg-ds-line-strong"}`}
|
||||
style={{ width: `${Math.max(row.rate * 100, row.errors > 0 ? 6 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`w-16 shrink-0 text-right font-[family-name:var(--font-data)] text-[12.5px] ${
|
||||
row.errors > 0 ? "text-ds-critical" : "text-ds-text-3"
|
||||
}`}
|
||||
>
|
||||
{row.errors > 0 ? `${(row.rate * 100).toFixed(1)}%` : "0%"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const IssueStatusPanel = ({ counts }: { counts: Record<StatusFilter, number> }) => {
|
||||
const total = counts.all || 1
|
||||
const segments: { key: StatusFilter; tone: SeverityTone }[] = [
|
||||
{ key: "open", tone: "critical" },
|
||||
{ key: "resolved", tone: "ok" },
|
||||
{ key: "ignored", tone: "muted" },
|
||||
]
|
||||
return (
|
||||
<section className="rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||||
<h2 className="mb-3 text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">Issue status</h2>
|
||||
<div className="mb-3 flex h-2 overflow-hidden rounded-full bg-ds-fill" aria-hidden="true">
|
||||
{segments.map((seg) => (
|
||||
<span
|
||||
key={seg.key}
|
||||
className={toneDot[seg.tone]}
|
||||
style={{ width: `${(counts[seg.key] / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{segments.map((seg) => (
|
||||
<li key={seg.key} className="flex items-center gap-2 text-[13px]">
|
||||
<span className={`h-2 w-2 rounded-full ${toneDot[seg.tone]}`} aria-hidden="true" />
|
||||
<span className="flex-1 capitalize text-ds-text-2">{seg.key}</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-ds-text">{counts[seg.key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<StatusFilter>("all")
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>("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<StatusFilter, number> = { 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 (
|
||||
<div className="py-12 text-center text-sm text-slate-400 flex flex-col items-center justify-center">
|
||||
<AlertOctagon className="h-10 w-10 mx-auto mb-4 opacity-20 animate-pulse" />
|
||||
Loading issues...
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center text-sm text-ds-text-3">
|
||||
<AlertOctagon className="mx-auto mb-4 h-10 w-10 opacity-30" aria-hidden="true" />
|
||||
Loading issues…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-3 py-2 border-b border-white/5 mb-2 bg-slate-950/80 backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
|
||||
<AlertOctagon className="h-3 w-3 text-rose-400" />
|
||||
Deduplicated Issues
|
||||
</span>
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
scope={{
|
||||
label: "Signal kind",
|
||||
options: scopeOptions,
|
||||
value: kindFilter,
|
||||
onChange: (v) => 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 */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
|
||||
{statusFilters.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
statusFilter === value
|
||||
? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 opacity-60">{statusCounts[value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setExcludeSpamIPs((value) => !value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
excludeSpamIPs
|
||||
? "border-amber-400/40 bg-amber-400/15 text-amber-200"
|
||||
: "border-white/10 text-slate-500 hover:border-white/20 hover:text-slate-300"
|
||||
}`}
|
||||
title="Hide HTTP errors whose recorded source IP is on the honeypot list"
|
||||
>
|
||||
<ShieldBan className="h-3 w-3" />
|
||||
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
|
||||
</button>
|
||||
|
||||
{/* Kind filter chips */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
|
||||
{kindFilters.map(({ value, label, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setKindFilter(value)}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
kindFilter === value
|
||||
? "bg-violet-500/20 text-violet-300 ring-1 ring-violet-500/30"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{filteredIssues.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{error && <span className="text-[10px] text-rose-400">Failed to load: {error}</span>}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !showSpamIPs
|
||||
setShowSpamIPs(next)
|
||||
if (next && spamIPs.length === 0) void loadSpamIPs()
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
|
||||
>
|
||||
<ShieldBan className="h-3 w-3" />
|
||||
Spam IPs ({spamIPs.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSpamIPs && (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-3 text-xs text-slate-300">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div className="font-bold text-amber-200">Promoted honeypot IPs</div>
|
||||
<div className="text-[10px] text-slate-500">Used by the ingress tarpit synchronizer.</div>
|
||||
<div className="flex gap-5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-[family-name:var(--font-display)] text-[22px] font-bold tracking-[-0.015em] text-ds-text">
|
||||
{statusFilter === "all" ? "Needs attention" : `${statusTabs.find((s) => s.value === statusFilter)?.label} issues`}
|
||||
</h1>
|
||||
<span className="inline-flex h-[22px] items-center rounded-[6px] bg-ds-critical-bg px-2 text-[11.5px] font-semibold text-ds-critical">
|
||||
{statusFilter === "all" ? `${primaryIssues.length} open` : filteredIssues.length}
|
||||
</span>
|
||||
<span className="flex-grow" />
|
||||
<div className="flex items-center gap-1 rounded-[9px] border border-ds-line bg-ds-surface p-0.5">
|
||||
{statusTabs.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(t.value)}
|
||||
aria-pressed={statusFilter === t.value}
|
||||
className={`rounded-[6px] px-2.5 py-1 text-[11.5px] font-semibold transition-colors ${
|
||||
statusFilter === t.value ? "bg-ds-fill text-ds-text" : "text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
<span className="ml-1 opacity-60">{statusCounts[t.value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSpamIPs(false)}
|
||||
className="rounded p-1 text-slate-500 hover:text-white"
|
||||
aria-label="Close spam IP list"
|
||||
type="button"
|
||||
onClick={() => setExcludeSpamIPs((v) => !v)}
|
||||
aria-pressed={excludeSpamIPs}
|
||||
title="Hide HTTP errors whose recorded source IP is on the honeypot list"
|
||||
className={`inline-flex h-9 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] font-medium transition-colors ${
|
||||
excludeSpamIPs
|
||||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||||
: "border-ds-line-strong text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<ShieldBan className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh()}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} aria-hidden="true" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{spamIPsLoading && <div className="text-slate-500">Loading…</div>}
|
||||
{spamIPsError && <div className="text-rose-300">Failed to load: {spamIPsError}</div>}
|
||||
{!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
|
||||
<div className="text-slate-500">No promoted IPs.</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px] text-ds-critical">Failed to load: {error}</p>
|
||||
)}
|
||||
{spamIPs.length > 0 && (
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{spamIPs.map((entry) => (
|
||||
<div key={entry.ip} className="flex items-center justify-between rounded-lg bg-black/20 px-2.5 py-1.5 font-mono">
|
||||
<span className="text-slate-200">{entry.ip}</span>
|
||||
<span className="text-[10px] text-amber-300">×{entry.count}</span>
|
||||
|
||||
{showSpamIPs && (
|
||||
<div className="rounded-[12px] border border-ds-warn/30 bg-ds-warn-bg p-3 text-[12.5px] text-ds-text-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-bold text-ds-warn">Promoted honeypot IPs</div>
|
||||
<div className="text-[11px] text-ds-text-3">Used by the ingress tarpit synchronizer.</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpamIPs(false)}
|
||||
aria-label="Close spam IP list"
|
||||
className="rounded p-1 text-ds-text-3 hover:text-ds-text"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{spamIPsLoading && <div className="text-ds-text-3">Loading…</div>}
|
||||
{spamIPsError && <div className="text-ds-critical">Failed to load: {spamIPsError}</div>}
|
||||
{!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
|
||||
<div className="text-ds-text-3">No promoted IPs.</div>
|
||||
)}
|
||||
{spamIPs.length > 0 && (
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{spamIPs.map((entry) => (
|
||||
<div key={entry.ip} className="flex items-center justify-between rounded-[8px] bg-ds-bg px-2.5 py-1.5 font-[family-name:var(--font-data)]">
|
||||
<span className="text-ds-text">{entry.ip}</span>
|
||||
<span className="text-[10.5px] text-ds-warn">×{entry.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showSpamIPs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSpamIPs(true)
|
||||
if (spamIPs.length === 0) void loadSpamIPs()
|
||||
}}
|
||||
className="self-start text-[11.5px] text-ds-text-3 hover:text-ds-text-2"
|
||||
>
|
||||
View promoted spam IPs ({spamIPs.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{primaryIssues.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||
<CheckCircle2 className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">
|
||||
{issues.length === 0 ? "No issues detected" : "Nothing matches your filters"}
|
||||
</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">
|
||||
{issues.length === 0
|
||||
? "Error-status spans and error/fatal-severity logs will show up here as they arrive."
|
||||
: "Try widening the scope or clearing a filter."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{primaryIssues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredIssues.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
|
||||
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||
<AlertOctagon className="h-6 w-6 text-emerald-400/60" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-300">
|
||||
{issues.length === 0 ? "No issues detected" : "No issues match your filters"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-slate-600">
|
||||
{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."}
|
||||
</p>
|
||||
{secondaryIssues.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecondary((v) => !v)}
|
||||
className="flex items-center gap-2.5 rounded-[12px] border border-dashed border-ds-line px-4 py-3 text-left text-[13px] text-ds-text-3 hover:text-ds-text-2"
|
||||
>
|
||||
{showSecondary ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{secondaryIssues.length} resolved or ignored
|
||||
</button>
|
||||
{showSecondary && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{secondaryIssues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
{filteredIssues.map((issue) => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
onStatusChange={onStatusChange}
|
||||
statusBusy={statusBusy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside className="flex w-[340px] shrink-0 flex-col gap-3.5">
|
||||
<ServiceDeviationPanel />
|
||||
<IssueStatusPanel counts={statusCounts} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<VitalRating, string> = {
|
||||
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<RumTimelineEntry["category"], string> = {
|
||||
"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"
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1 rounded-[12px] border border-ds-line bg-ds-surface px-4 py-3">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[22px] font-bold ${
|
||||
tone === "critical" ? "text-ds-critical" : tone === "warn" ? "text-ds-warn" : tone === "ok" ? "text-ds-ok" : "text-ds-text"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[12px] text-ds-text-3">{detail}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DeviceIcon = ({ isMobile }: { isMobile: boolean }) =>
|
||||
isMobile ? (
|
||||
<Smartphone className="h-3.5 w-3.5 shrink-0 text-ds-text-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Laptop className="h-3.5 w-3.5 shrink-0 text-ds-text-3" aria-hidden="true" />
|
||||
)
|
||||
|
||||
/** The per-row "session timeline" bar: real event shape, segments sized by real elapsed time. */
|
||||
const SessionTimelineBar = ({ session }: { session: RumSession }) => {
|
||||
const span = Math.max(session.lastSeen - session.firstSeen, 1)
|
||||
const entries = session.timeline
|
||||
if (entries.length <= 1) {
|
||||
return <span className="block h-2.5 w-full rounded-[2px] bg-ds-line-strong" aria-hidden="true" />
|
||||
}
|
||||
const segments = entries.slice(1).map((entry, i) => {
|
||||
const prevT = entries[i].timestamp
|
||||
const pct = Math.max(((entry.timestamp - prevT) / span) * 100, 3)
|
||||
return { pct, severe: entry.severe }
|
||||
})
|
||||
return (
|
||||
<span className="flex h-2.5 w-full gap-0.5" aria-hidden="true">
|
||||
{segments.map((seg, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-[2px] ${seg.severe ? "bg-ds-critical" : "bg-ds-line-strong"}`}
|
||||
style={{ flex: `${seg.pct} 0 0` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const SessionRow = ({
|
||||
session,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
session: RumSession
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
}) => {
|
||||
const hasIssues = session.errorCount > 0 || session.httpFailureCount > 0
|
||||
const inp = session.vitals.INP
|
||||
const inpRating = inp !== undefined ? getVitalRating("INP", inp) : null
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-4 border-t border-ds-line px-4 py-3 text-left first:border-t-0 ${
|
||||
isSelected ? "bg-ds-surface-2 border-l-[3px] border-l-ds-accent" : "border-l-[3px] border-l-transparent hover:bg-ds-fill/40"
|
||||
}`}
|
||||
>
|
||||
<span className="flex w-[168px] shrink-0 flex-col gap-0.5">
|
||||
<span className="font-[family-name:var(--font-data)] text-[12.5px] text-ds-text">{session.id}</span>
|
||||
<span className="text-[11px] text-ds-text-3">
|
||||
{session.urls[0] ?? "no page recorded"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex w-[136px] shrink-0 items-center gap-1.5 text-[12.5px] text-ds-text-2">
|
||||
<DeviceIcon isMobile={session.device.isMobile} />
|
||||
{session.device.os} · {session.device.browser}
|
||||
</span>
|
||||
<span
|
||||
className={`w-[68px] shrink-0 font-[family-name:var(--font-data)] text-[12.5px] ${
|
||||
inpRating ? vitalTone[inpRating] : "text-ds-text-3"
|
||||
}`}
|
||||
>
|
||||
{inp !== undefined ? VITAL_THRESHOLDS.INP.format(inp) : "—"}
|
||||
</span>
|
||||
<span
|
||||
className={`w-[64px] shrink-0 font-[family-name:var(--font-data)] text-[12.5px] ${
|
||||
hasIssues ? "text-ds-critical" : "text-ds-text-3"
|
||||
}`}
|
||||
>
|
||||
{session.errorCount + session.httpFailureCount}
|
||||
</span>
|
||||
<span className="min-w-[120px] flex-1">
|
||||
<SessionTimelineBar session={session} />
|
||||
</span>
|
||||
<span className="w-[70px] shrink-0 text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3">
|
||||
{formatRelativeTime(session.lastSeen)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<aside className="flex w-[380px] shrink-0 flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line p-8 text-center">
|
||||
<Radio className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[13px] text-ds-text-3">Select a session to see what happened.</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const latestTracedError = session.errors.find((e) => e.traceId)
|
||||
|
||||
const copySessionId = () => {
|
||||
void navigator.clipboard.writeText(session.id)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
const viewTrace = (traceId: string) => {
|
||||
setActiveTraceId(traceId)
|
||||
setActiveSpanId(null)
|
||||
setActiveTab("tab1")
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex w-[380px] shrink-0 flex-col gap-4 overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-ds-fill text-ds-text-2"
|
||||
>
|
||||
<DeviceIcon isMobile={session.device.isMobile} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="font-[family-name:var(--font-data)] text-[13px] font-semibold text-ds-text">
|
||||
{session.device.os} · {session.device.browser}
|
||||
</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||
{session.id}
|
||||
{session.environment ? ` · ${session.environment}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySessionId}
|
||||
aria-label="Copy session id"
|
||||
title="Copy session id"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-ds-text-3 hover:bg-ds-fill hover:text-ds-text-2"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 border-y border-ds-fill py-3">
|
||||
{(["LCP", "INP", "CLS"] as const).map((name) => {
|
||||
const value = session.vitals[name]
|
||||
const rating = value !== undefined ? getVitalRating(name, value) : null
|
||||
return (
|
||||
<div key={name} className="flex flex-col gap-1">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{name}</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[17px] font-bold ${
|
||||
rating ? vitalTone[rating] : "text-ds-text-3"
|
||||
}`}
|
||||
>
|
||||
{value !== undefined ? VITAL_THRESHOLDS[name].format(value) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">Errors</span>
|
||||
<span
|
||||
className={`font-[family-name:var(--font-display)] text-[17px] font-bold ${
|
||||
session.errorCount + session.httpFailureCount > 0 ? "text-ds-critical" : "text-ds-ok"
|
||||
}`}
|
||||
>
|
||||
{session.errorCount + session.httpFailureCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">What happened</span>
|
||||
<ol className="flex flex-1 flex-col gap-3 overflow-y-auto">
|
||||
{session.timeline.map((entry, idx) => (
|
||||
<li key={idx} className="flex gap-3">
|
||||
<span className="w-11 shrink-0 pt-0.5 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||
{formatElapsed(entry.timestamp - session.firstSeen)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1 h-[9px] w-[9px] shrink-0 rounded-full ${
|
||||
entry.severe ? "bg-ds-critical" : "bg-ds-line-strong"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className={`text-[13px] ${entry.severe ? "font-semibold text-ds-critical" : "text-ds-text"}`}>
|
||||
{categoryLabel[entry.category]}
|
||||
</span>
|
||||
<span className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">
|
||||
{entry.message}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{latestTracedError?.traceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewTrace(latestTracedError.traceId!)}
|
||||
className="inline-flex h-[42px] items-center justify-center gap-2 rounded-[10px] bg-ds-accent text-[13.5px] font-semibold text-ds-on-accent"
|
||||
>
|
||||
<ExternalLink className="h-[15px] w-[15px]" aria-hidden="true" />
|
||||
View trace
|
||||
</button>
|
||||
)}
|
||||
{copied && <span className="text-center text-[11px] text-ds-ok">Session id copied</span>}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export const RumSessionsVisualizer = () => {
|
||||
const { logs, searchQuery, setSearchQuery } = useObservabilityData()
|
||||
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
|
||||
const [errorsOnly, setErrorsOnly] = useState(false)
|
||||
const [sortMode, setSortMode] = useState<SortMode>("worst")
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
|
||||
const sessions = useMemo(() => buildRumSessions(logs), [logs])
|
||||
const aggregates = useMemo(() => aggregateRum(sessions), [sessions])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return sessions.filter((s) => {
|
||||
if (deviceFilter === "mobile" && !s.device.isMobile) return false
|
||||
if (deviceFilter === "desktop" && s.device.isMobile) return false
|
||||
if (errorsOnly && s.errorCount === 0 && s.httpFailureCount === 0) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const haystack = [s.id, ...s.urls, ...s.errors.map((e) => e.message)].join(" ").toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [sessions, deviceFilter, errorsOnly, searchQuery])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (sortMode === "recent") return [...filtered].sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
return filtered
|
||||
}, [filtered, sortMode])
|
||||
|
||||
const selected = sorted.find((s) => s.id === selectedSessionId) ?? sorted[0] ?? null
|
||||
|
||||
const activeFilterCount = (deviceFilter !== "all" ? 1 : 0) + (errorsOnly ? 1 : 0) + (searchQuery ? 1 : 0)
|
||||
|
||||
const chips: FilterChip[] = [
|
||||
...(deviceFilter !== "all"
|
||||
? [
|
||||
{
|
||||
id: "device",
|
||||
filterKey: "device.type",
|
||||
value: deviceFilter,
|
||||
tone: "neutral" as const,
|
||||
onRemove: () => setDeviceFilter("all"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(errorsOnly
|
||||
? [
|
||||
{
|
||||
id: "errors",
|
||||
filterKey: "status",
|
||||
value: "has errors",
|
||||
tone: "critical" as const,
|
||||
onRemove: () => setErrorsOnly(false),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(searchQuery
|
||||
? [
|
||||
{
|
||||
id: "query",
|
||||
filterKey: "query",
|
||||
value: searchQuery,
|
||||
tone: "neutral" as const,
|
||||
onRemove: () => setSearchQuery(""),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const addFilterOptions = [
|
||||
...(deviceFilter !== "mobile" ? [{ value: "device:mobile", label: "device.type=mobile" }] : []),
|
||||
...(deviceFilter !== "desktop" ? [{ value: "device:desktop", label: "device.type=desktop" }] : []),
|
||||
...(!errorsOnly ? [{ value: "errors:yes", label: "has errors" }] : []),
|
||||
]
|
||||
|
||||
const onAddFilter = (value: string) => {
|
||||
if (value === "device:mobile") setDeviceFilter("mobile")
|
||||
else if (value === "device:desktop") setDeviceFilter("desktop")
|
||||
else if (value === "errors:yes") setErrorsOnly(true)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
setDeviceFilter("all")
|
||||
setErrorsOnly(false)
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
const inpRating = aggregates.inpP75 !== null ? getVitalRating("INP", aggregates.inpP75) : null
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar chips={[]} matchText="Matching 0 of 0 sessions · 0 filters" />
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
|
||||
<Monitor className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">No real-user sessions yet</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">
|
||||
Sessions appear here once otel-rum.js reports Web Vitals, errors, or navigation from a browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
chips={chips}
|
||||
addFilterOptions={addFilterOptions}
|
||||
onAddFilter={onAddFilter}
|
||||
matchText={`Matching ${filtered.length} of ${sessions.length} sessions · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`}
|
||||
onClearAll={activeFilterCount > 0 ? clearAll : undefined}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatTile
|
||||
label="Sessions with errors"
|
||||
value={String(aggregates.sessionsWithErrors)}
|
||||
detail={`${aggregates.totalSessions > 0 ? Math.round((aggregates.sessionsWithErrors / aggregates.totalSessions) * 100) : 0}% of ${aggregates.totalSessions} sessions`}
|
||||
tone={aggregates.sessionsWithErrors > 0 ? "critical" : "ok"}
|
||||
/>
|
||||
<StatTile
|
||||
label="JS errors"
|
||||
value={String(aggregates.totalJsErrors)}
|
||||
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
|
||||
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
|
||||
/>
|
||||
<StatTile
|
||||
label="HTTP failures"
|
||||
value={String(aggregates.totalHttpFailures)}
|
||||
detail="failed fetch/XHR requests"
|
||||
tone={aggregates.totalHttpFailures > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<StatTile
|
||||
label="INP p75"
|
||||
value={aggregates.inpP75 !== null ? VITAL_THRESHOLDS.INP.format(aggregates.inpP75) : "No data"}
|
||||
detail="interaction responsiveness"
|
||||
tone={inpRating === "poor" ? "critical" : inpRating === "needs-improvement" ? "warn" : "ok"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<section className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface">
|
||||
<div className="flex h-11 items-center gap-3 border-b border-ds-line px-4">
|
||||
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
|
||||
Sessions · worst experience first
|
||||
</h2>
|
||||
<span className="flex-grow" />
|
||||
<div className="flex items-center gap-1 rounded-[8px] border border-ds-line bg-ds-bg p-0.5">
|
||||
{(["worst", "recent"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setSortMode(mode)}
|
||||
aria-pressed={sortMode === mode}
|
||||
className={`rounded-[6px] px-2.5 py-1 text-[11.5px] font-semibold transition-colors ${
|
||||
sortMode === mode ? "bg-ds-fill text-ds-text" : "text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
{mode === "worst" ? "Worst experience" : "Most recent"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-b border-ds-line px-4 py-2 text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">
|
||||
<span className="w-[168px]">Session</span>
|
||||
<span className="w-[136px]">Device</span>
|
||||
<span className="w-[68px]">INP</span>
|
||||
<span className="w-[64px]">Errors</span>
|
||||
<span className="min-w-[120px] flex-1">Session timeline</span>
|
||||
<span className="w-[70px] text-right">Last seen</span>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[13px] text-ds-text-3">No sessions match your filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{sorted.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
isSelected={selected?.id === s.id}
|
||||
onSelect={() => setSelectedSessionId(s.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SessionDetail session={selected} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RumSessionsVisualizer
|
||||
@@ -1,344 +0,0 @@
|
||||
import React from "react";
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData";
|
||||
import { useIssues } from "../hooks/useIssues";
|
||||
import { useStatsAvailability } from "../hooks/useStats";
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
traces,
|
||||
logs,
|
||||
metrics,
|
||||
isConnected,
|
||||
uniqueServices,
|
||||
serviceHealth,
|
||||
selectedServices,
|
||||
toggleService,
|
||||
resetFilters,
|
||||
} = useObservabilityData();
|
||||
|
||||
const { issues } = useIssues();
|
||||
const statsAvailable = useStatsAvailability();
|
||||
|
||||
// Active open issues count
|
||||
const openIssuesCount = React.useMemo(() => {
|
||||
return issues.filter((i) => !i.status || i.status === "open").length;
|
||||
}, [issues]);
|
||||
|
||||
const errorLogCount = React.useMemo(() => {
|
||||
let errs = 0;
|
||||
logs.forEach((log) => {
|
||||
(log.scopeLogs ?? []).forEach((sl) => {
|
||||
(sl.logRecords ?? []).forEach((r) => {
|
||||
if ((r.severityNumber ?? 0) >= 17) errs += 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
return errs;
|
||||
}, [logs]);
|
||||
|
||||
const totalSpans = React.useMemo(() => {
|
||||
let count = 0;
|
||||
traces.forEach((t) => {
|
||||
(t.resourceSpans ?? []).forEach((rs) => {
|
||||
(rs.scopeSpans ?? []).forEach((ss) => {
|
||||
count += (ss.spans ?? []).length;
|
||||
});
|
||||
});
|
||||
});
|
||||
return count;
|
||||
}, [traces]);
|
||||
|
||||
const { frontendCount, frontendErrorCount } = React.useMemo(() => {
|
||||
let count = 0;
|
||||
let errors = 0;
|
||||
logs.forEach((log) => {
|
||||
(log.scopeLogs ?? []).forEach((sl) => {
|
||||
(sl.logRecords ?? []).forEach((r) => {
|
||||
const attrs = r.attributes ?? [];
|
||||
const source = attrs.find((a) => a.key === "source")?.value.stringValue;
|
||||
const isRum = sl.scope?.name === "otel-rum";
|
||||
const vital = attrs.find((a) => a.key === "rum.vital")?.value.stringValue;
|
||||
const isFrontend = source === "frontend" || isRum || Boolean(vital);
|
||||
if (isFrontend) {
|
||||
count++;
|
||||
if ((r.severityNumber ?? 0) >= 17 || attrs.some((a) => a.key === "exception.type")) {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return { frontendCount: count, frontendErrorCount: errors };
|
||||
}, [logs]);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
tab: "tab0",
|
||||
label: "Issues & Anomalies",
|
||||
icon: "error",
|
||||
badge: openIssuesCount > 0 ? `${openIssuesCount} active` : `${issues.length} total`,
|
||||
badgeType: openIssuesCount > 0 ? ("error" as const) : ("outline" as const),
|
||||
},
|
||||
{
|
||||
tab: "tab8",
|
||||
label: "Similarity Patterns",
|
||||
icon: "hub",
|
||||
badge: undefined,
|
||||
badgeType: "outline" as const,
|
||||
},
|
||||
{
|
||||
tab: "tab9",
|
||||
label: "Cluster Rollouts",
|
||||
icon: "cloud_sync",
|
||||
badge: undefined,
|
||||
badgeType: "outline" as const,
|
||||
},
|
||||
{
|
||||
tab: "tab1",
|
||||
label: "Traces & Spans",
|
||||
icon: "account_tree",
|
||||
badge: totalSpans > 0 ? `${totalSpans} spans` : `${traces.length} traces`,
|
||||
badgeType: totalSpans > 0 ? ("secondary" as const) : ("outline" as const),
|
||||
},
|
||||
{
|
||||
tab: "tab3",
|
||||
label: "Live Log Stream",
|
||||
icon: "terminal",
|
||||
badge: errorLogCount > 0 ? `${errorLogCount} err` : `${logs.length} logs`,
|
||||
badgeType: errorLogCount > 0 ? ("error" as const) : ("secondary" as const),
|
||||
},
|
||||
{
|
||||
tab: "tab2",
|
||||
label: "Metrics & APM",
|
||||
icon: "query_stats",
|
||||
badge: `${metrics.length} metrics`,
|
||||
badgeType: "outline" as const,
|
||||
},
|
||||
{
|
||||
tab: "tab7",
|
||||
label: "Frontend & RUM",
|
||||
icon: "devices",
|
||||
badge:
|
||||
frontendCount > 0
|
||||
? frontendErrorCount > 0
|
||||
? `${frontendErrorCount} err`
|
||||
: `${frontendCount} sig`
|
||||
: "RUM",
|
||||
badgeType: frontendErrorCount > 0 ? ("error" as const) : ("secondary" as const),
|
||||
},
|
||||
...(statsAvailable
|
||||
? [
|
||||
{
|
||||
tab: "tab5",
|
||||
label: "Historical Trends",
|
||||
icon: "trending_up",
|
||||
badge: "ClickHouse",
|
||||
badgeType: "outline" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tab: "tab6",
|
||||
label: "Archive & Trap",
|
||||
icon: "radar",
|
||||
badge: "Deep Query",
|
||||
badgeType: "secondary" as const,
|
||||
},
|
||||
{
|
||||
tab: "tab4",
|
||||
label: "Setup & Agent API",
|
||||
icon: "build",
|
||||
badge: "RUM / Doc",
|
||||
badgeType: "outline" as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 h-full w-72 bg-surface-container-lowest z-50 flex flex-col justify-between select-none shadow-[0_1px_8px_rgba(0,0,0,0.04)] border-r border-surface-container/60">
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
{/* Brand Header */}
|
||||
<div className="h-14 px-4 flex items-center justify-between bg-surface-container-lowest border-b border-surface-container/40">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-container to-secondary text-on-primary font-black text-sm shadow-md">
|
||||
<span className="material-symbols-outlined text-[18px]">hub</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-headline font-semibold text-base tracking-tight text-on-surface leading-none">
|
||||
HyperScope
|
||||
</span>
|
||||
<span className="font-mono text-[10px] px-1 py-0.5 rounded bg-surface-container-high text-primary-container leading-none uppercase font-bold">
|
||||
OTel
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-outline mt-0.5 leading-none">
|
||||
Realtime Observability
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-full font-mono text-[9px] font-bold uppercase tracking-wider ${
|
||||
isConnected
|
||||
? "bg-secondary-container/20 text-secondary border border-secondary-container/40"
|
||||
: "bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isConnected ? "bg-secondary animate-pulse" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
<span>{isConnected ? "LIVE" : "RECONNECT"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Telemetry Core Navigation */}
|
||||
<div className="px-3 mt-3">
|
||||
<div className="px-2 pb-1.5 font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
|
||||
Telemetry Views
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeTab === item.tab;
|
||||
return (
|
||||
<button
|
||||
key={item.tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.tab)}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition-colors group text-left ${
|
||||
isActive
|
||||
? "bg-surface-container-high text-primary font-semibold shadow-sm"
|
||||
: "text-on-surface-variant hover:bg-surface-container hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] transition-colors ${
|
||||
isActive
|
||||
? "text-primary-container"
|
||||
: "text-outline group-hover:text-primary-container"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-xs font-body font-medium">{item.label}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span
|
||||
className={`font-mono text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
item.badgeType === "error"
|
||||
? "bg-error-container text-on-error-container font-semibold"
|
||||
: item.badgeType === "secondary"
|
||||
? "bg-surface-container text-secondary"
|
||||
: "bg-surface-container text-outline"
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Real Discovered Services Fleet List */}
|
||||
<div className="px-3 mt-5 flex-1">
|
||||
<div className="px-2 pb-1.5 flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
|
||||
Discovered Services ({uniqueServices.length})
|
||||
</span>
|
||||
{selectedServices.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="font-mono text-[10px] text-primary-container hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{uniqueServices.length === 0 ? (
|
||||
<div className="px-2.5 py-3 text-center rounded-lg bg-surface-container-low/50 text-[11px] text-outline italic">
|
||||
Listening for service signals...
|
||||
</div>
|
||||
) : (
|
||||
uniqueServices.map((service) => {
|
||||
const isSelected = selectedServices.has(service);
|
||||
const health = serviceHealth[service];
|
||||
const hasErrors = health?.hasErrors || false;
|
||||
const errorCount = health?.errorCount || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={service}
|
||||
type="button"
|
||||
onClick={() => toggleService(service)}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary-container/20 text-primary-container border border-primary-container/40 font-semibold"
|
||||
: "text-on-surface-variant hover:bg-surface-container hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 pr-1">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
hasErrors
|
||||
? "bg-error animate-pulse"
|
||||
: isSelected
|
||||
? "bg-primary-container"
|
||||
: "bg-emerald-400/80"
|
||||
}`}
|
||||
title={hasErrors ? `${errorCount} errors in active window` : "Healthy"}
|
||||
/>
|
||||
<span className="truncate text-xs">{service}</span>
|
||||
</div>
|
||||
|
||||
{hasErrors ? (
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-error-container text-on-error-container font-semibold shrink-0">
|
||||
{errorCount} err
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-outline shrink-0">
|
||||
{health?.totalSignals ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Receiver Endpoints & WebSocket Health Status */}
|
||||
<div className="p-3 bg-surface-container-lowest border-t border-surface-container/40">
|
||||
<div className="p-2.5 rounded-lg bg-surface-container-low flex flex-col gap-1.5 border border-surface-container/40 font-mono text-[11px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-outline">Receiver Ports</span>
|
||||
<span className="text-on-surface font-semibold">4317 / 4318</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-outline">Web UI & API</span>
|
||||
<span className="text-on-surface font-semibold">:8080</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1 border-t border-surface-container/40">
|
||||
<span className="text-outline">Live Pipeline</span>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
isConnected ? "text-secondary" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{isConnected ? "Connected" : "Reconnecting..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import TraceVisualization from "./TraceVisualization"
|
||||
import { IssuesVisualizer } from "./IssuesVisualizer"
|
||||
import { SetupPanel } from "./SetupPanel"
|
||||
import { SearchAndWatcher } from "./SearchAndWatcher"
|
||||
import { FrontendVisualizer } from "./FrontendVisualizer"
|
||||
import { RumSessionsVisualizer } from "./RumSessionsVisualizer"
|
||||
import { ViewErrorBoundary } from "./ViewErrorBoundary"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { useIssues } from "../hooks/useIssues"
|
||||
@@ -375,8 +375,8 @@ export const TabsComponent = () => {
|
||||
},
|
||||
{
|
||||
value: "tab7",
|
||||
label: "Frontend & RUM",
|
||||
description: "Web Vitals & browser errors",
|
||||
label: "RUM sessions",
|
||||
description: "Real-user sessions, worst experience first",
|
||||
Icon: Globe,
|
||||
count: frontendCount,
|
||||
},
|
||||
@@ -484,7 +484,7 @@ export const TabsComponent = () => {
|
||||
className="outline-none py-2"
|
||||
value="tab7"
|
||||
>
|
||||
{activeTab === "tab7" && <ViewErrorBoundary><FrontendVisualizer /></ViewErrorBoundary>}
|
||||
{activeTab === "tab7" && <ViewErrorBoundary><RumSessionsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData";
|
||||
import { useIssues } from "../hooks/useIssues";
|
||||
import { currentUser, login, logout } from "../lib/auth";
|
||||
|
||||
export const TopHeader: React.FC = () => {
|
||||
const user = currentUser();
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
showErrorsOnly,
|
||||
setShowErrorsOnly,
|
||||
resetFilters,
|
||||
setActiveTab,
|
||||
timeWindow,
|
||||
setTimeWindow,
|
||||
isStreaming,
|
||||
setIsStreaming,
|
||||
bufferedCount,
|
||||
flushBufferedStream,
|
||||
minDurationMs,
|
||||
setMinDurationMs,
|
||||
} = useObservabilityData();
|
||||
|
||||
const { issues } = useIssues();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const openIssuesCount = React.useMemo(() => {
|
||||
return issues.filter((i) => !i.status || i.status === "open").length;
|
||||
}, [issues]);
|
||||
|
||||
// ⌘K / Ctrl+K keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-72 right-0 h-14 bg-surface-container-lowest/90 backdrop-blur-xl z-40 flex items-center justify-between px-6 shadow-[0_1px_8px_rgba(0,0,0,0.04)] border-b border-surface-container/60 font-body">
|
||||
{/* Search and Time Range */}
|
||||
<div className="flex items-center gap-3 flex-1 max-w-3xl">
|
||||
{/* Global Search Bar */}
|
||||
<div className="relative flex-1">
|
||||
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-outline text-[18px] pointer-events-none">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="⌘K search attributes, services, traceId, spanId, or log payload..."
|
||||
className="w-full h-8 pl-8 pr-16 rounded-lg bg-surface-container-low text-on-surface font-mono text-xs placeholder:text-outline focus:outline-none focus:bg-surface-container transition-colors border border-surface-container/40"
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5 pointer-events-none select-none">
|
||||
<kbd className="font-mono text-[10px] text-outline px-1 rounded bg-surface-container-high border border-white/5">
|
||||
⌘
|
||||
</kbd>
|
||||
<kbd className="font-mono text-[10px] text-outline px-1 rounded bg-surface-container-high border border-white/5">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real Time Window Selector */}
|
||||
<div className="flex items-center bg-surface-container-low rounded-lg p-0.5 border border-surface-container/40 select-none">
|
||||
<span className="material-symbols-outlined text-outline text-[14px] ml-1 mr-0.5">
|
||||
schedule
|
||||
</span>
|
||||
{(["15m", "1h", "6h", "24h", "all"] as const).map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setTimeWindow(w)}
|
||||
className={`px-2 py-0.5 font-mono text-[11px] rounded transition-colors ${
|
||||
timeWindow === w
|
||||
? "bg-surface-container text-primary-container font-bold shadow-sm"
|
||||
: "text-outline hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{w === "all" ? "All" : w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons & Streaming Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Real Streaming Pause/Resume Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isStreaming) {
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
setIsStreaming(true);
|
||||
flushBufferedStream();
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] font-semibold transition-colors border ${
|
||||
isStreaming
|
||||
? "bg-surface-container-low text-secondary border-surface-container hover:bg-surface-container"
|
||||
: "bg-amber-500/20 text-amber-300 border-amber-500/40 animate-pulse"
|
||||
}`}
|
||||
title={isStreaming ? "Pause stream to freeze buffer for inspection" : "Resume live telemetry tail"}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isStreaming ? "bg-secondary animate-pulse" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{isStreaming
|
||||
? "Live Tail"
|
||||
: `Paused ${bufferedCount > 0 ? `(${bufferedCount})` : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* All vs Errors Toggle */}
|
||||
<div className="flex rounded-lg bg-surface-container-low p-0.5 border border-surface-container/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowErrorsOnly(false)}
|
||||
className={`px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
|
||||
!showErrorsOnly
|
||||
? "bg-surface-container-high text-primary font-bold shadow-sm"
|
||||
: "text-outline hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowErrorsOnly(true)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
|
||||
showErrorsOnly
|
||||
? "bg-error-container text-on-error-container font-bold shadow-sm"
|
||||
: "text-outline hover:text-error"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
Errors
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Slow Spans Toggle (>500ms) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinDurationMs(minDurationMs > 0 ? 0 : 500)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded-lg border transition-colors ${
|
||||
minDurationMs > 0
|
||||
? "bg-amber-500/20 text-amber-300 border-amber-500/40 font-bold"
|
||||
: "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
|
||||
}`}
|
||||
title="Filter to slow spans (>500ms latency)"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">speed</span>
|
||||
<span>>500ms</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetFilters();
|
||||
setMinDurationMs(0);
|
||||
}}
|
||||
className="px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider text-outline hover:text-on-surface rounded-lg bg-surface-container-low hover:bg-surface-container border border-surface-container/40 transition-colors"
|
||||
title="Reset active filters"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-surface-container-high" />
|
||||
|
||||
{/* Active Issues / Incident Counter Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab0")}
|
||||
aria-label="Active issues"
|
||||
className={`relative flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-xs font-bold transition-colors border ${
|
||||
openIssuesCount > 0
|
||||
? "bg-error-container text-on-error-container border-error/30 animate-pulse"
|
||||
: "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
|
||||
}`}
|
||||
title="Open Issues & Anomalies"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">notifications_active</span>
|
||||
<span>{openIssuesCount}</span>
|
||||
</button>
|
||||
|
||||
{/* User Account / Auth */}
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2 pl-1 select-none">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-xs border border-primary/20 shadow-sm"
|
||||
title={user.email || user.sub}
|
||||
>
|
||||
{(user.name || user.email || user.sub).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="hidden xl:flex flex-col text-left">
|
||||
<span className="font-body text-xs text-on-surface leading-tight font-medium truncate max-w-[120px]">
|
||||
{user.name || user.email?.split("@")[0] || user.sub}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
className="font-mono text-[10px] text-outline hover:text-error text-left leading-none transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => login()}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 font-mono text-xs transition-colors border border-primary/20 select-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">login</span>
|
||||
<span>Sign in</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react"
|
||||
import {
|
||||
Activity,
|
||||
AlertOctagon,
|
||||
CloudCog,
|
||||
GitBranch,
|
||||
Globe,
|
||||
LineChart,
|
||||
Radar,
|
||||
ScrollText,
|
||||
TrendingUp,
|
||||
Waypoints,
|
||||
Wrench,
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||
import { useIssues } from "../../hooks/useIssues"
|
||||
import { useStatsAvailability } from "../../hooks/useStats"
|
||||
|
||||
interface RailItem {
|
||||
tab: string
|
||||
label: string
|
||||
Icon: LucideIcon
|
||||
dotTone?: "critical" | "warn"
|
||||
dotVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The 72px fixed icon rail: primary navigation for every screen. Icon-only
|
||||
* with a real aria-label per control (spec: "72px icon rail, fixed. 46x46
|
||||
* targets, aria-label on every one"). A small status dot only appears when
|
||||
* that view has something real to flag (open issues, error logs) - it is
|
||||
* never decorative.
|
||||
*/
|
||||
export const IconRail: React.FC = () => {
|
||||
const { activeTab, setActiveTab, logs } = useObservabilityData()
|
||||
const { issues } = useIssues()
|
||||
const statsAvailable = useStatsAvailability()
|
||||
|
||||
const openIssuesCount = React.useMemo(
|
||||
() => issues.filter((i) => !i.status || i.status === "open").length,
|
||||
[issues],
|
||||
)
|
||||
|
||||
const errorLogCount = React.useMemo(() => {
|
||||
let errs = 0
|
||||
logs.forEach((log) => {
|
||||
;(log.scopeLogs ?? []).forEach((sl) => {
|
||||
;(sl.logRecords ?? []).forEach((r) => {
|
||||
if ((r.severityNumber ?? 0) >= 17) errs += 1
|
||||
})
|
||||
})
|
||||
})
|
||||
return errs
|
||||
}, [logs])
|
||||
|
||||
const items: RailItem[] = [
|
||||
{ tab: "tab0", label: "Triage overview", Icon: AlertOctagon, dotTone: "critical", dotVisible: openIssuesCount > 0 },
|
||||
{ tab: "tab8", label: "Similarity patterns", Icon: GitBranch },
|
||||
{ tab: "tab9", label: "Cluster rollouts", Icon: CloudCog },
|
||||
{ tab: "tab1", label: "Traces & spans", Icon: Activity },
|
||||
{ tab: "tab3", label: "Live log stream", Icon: ScrollText, dotTone: "warn", dotVisible: errorLogCount > 0 },
|
||||
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
||||
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
||||
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
||||
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed left-0 top-0 z-50 flex h-full w-[72px] flex-col items-center gap-1.5 border-r border-ds-line bg-ds-chrome py-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab0")}
|
||||
aria-label="Stackship — go to triage overview"
|
||||
className="mb-2 flex h-[34px] w-[34px] items-center justify-center rounded-[10px] bg-ds-accent-bg text-ds-accent"
|
||||
>
|
||||
<Waypoints className="h-[19px] w-[19px]" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{items.map((item) => {
|
||||
const isActive = activeTab === item.tab
|
||||
return (
|
||||
<button
|
||||
key={item.tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.tab)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
title={item.label}
|
||||
className={`relative flex h-[46px] w-[46px] items-center justify-center rounded-xl transition-colors ${
|
||||
isActive ? "bg-ds-surface-2 text-ds-text" : "text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
<item.Icon className="h-5 w-5" aria-hidden="true" />
|
||||
{item.dotVisible && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-2 top-2 h-[7px] w-[7px] rounded-full ${
|
||||
item.dotTone === "critical" ? "bg-ds-critical" : "bg-ds-warn"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<span className="flex-grow" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tab4")}
|
||||
aria-label="Setup & agent API"
|
||||
aria-current={activeTab === "tab4" ? "page" : undefined}
|
||||
title="Setup & agent API"
|
||||
className={`flex h-[46px] w-[46px] items-center justify-center rounded-xl transition-colors ${
|
||||
activeTab === "tab4" ? "bg-ds-surface-2 text-ds-text" : "text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconRail
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { LogIn, Moon, Search, Sun } from "lucide-react"
|
||||
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||
import { currentUser, login, logout } from "../../lib/auth"
|
||||
import { getTheme, setTheme, type ThemeMode } from "../../lib/theme"
|
||||
import type { TimeWindow } from "../../contexts/ObservabilityContextValue"
|
||||
|
||||
const TIME_WINDOWS: { value: TimeWindow; label: string }[] = [
|
||||
{ value: "15m", label: "15m" },
|
||||
{ value: "1h", label: "1h" },
|
||||
{ value: "6h", label: "6h" },
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "all", label: "All" },
|
||||
]
|
||||
|
||||
/**
|
||||
* 56-60px top bar: connection context on the left, search in the middle,
|
||||
* time range and identity on the right (design spec layout section).
|
||||
*/
|
||||
export const TopBar: React.FC = () => {
|
||||
const user = currentUser()
|
||||
const { searchQuery, setSearchQuery, timeWindow, setTimeWindow, isConnected } =
|
||||
useObservabilityData()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [theme, setThemeState] = useState<ThemeMode>(() => getTheme())
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next: ThemeMode = theme === "dark" ? "light" : "dark"
|
||||
setTheme(next)
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className="fixed left-[72px] right-0 top-0 z-40 flex h-[58px] items-center gap-4 border-b border-ds-line bg-ds-chrome px-6 font-[family-name:var(--font-ui)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-[family-name:var(--font-display)] text-base font-bold tracking-[-0.01em] text-ds-text">
|
||||
Stackship
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${
|
||||
isConnected
|
||||
? "border-ds-ok/30 bg-ds-ok/10 text-ds-ok"
|
||||
: "border-ds-warn/30 bg-ds-warn-bg text-ds-warn"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-[6px] w-[6px] rounded-full ${isConnected ? "bg-ds-ok" : "bg-ds-warn animate-pulse"}`}
|
||||
/>
|
||||
{isConnected ? "Live" : "Reconnecting"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-xl">
|
||||
<label htmlFor="global-search" className="sr-only">
|
||||
Search attributes, services, trace id, span id, or log payload
|
||||
</label>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ds-text-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="global-search"
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search trace id, service, route, user…"
|
||||
className="h-9 w-full rounded-[9px] border border-ds-line bg-ds-surface-2 pl-9 pr-14 font-[family-name:var(--font-data)] text-[13px] text-ds-text placeholder:text-ds-text-3 focus:outline-none focus:ring-1 focus:ring-ds-accent"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<kbd className="rounded border border-ds-line-strong bg-ds-fill px-1 font-[family-name:var(--font-data)] text-[10px] text-ds-text-3">
|
||||
⌘
|
||||
</kbd>
|
||||
<kbd className="rounded border border-ds-line-strong bg-ds-fill px-1 font-[family-name:var(--font-data)] text-[10px] text-ds-text-3">
|
||||
K
|
||||
</kbd>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Time range"
|
||||
className="flex items-center gap-0.5 rounded-[9px] border border-ds-line bg-ds-surface-2 p-[3px]"
|
||||
>
|
||||
{TIME_WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.value}
|
||||
type="button"
|
||||
onClick={() => setTimeWindow(w.value)}
|
||||
aria-pressed={timeWindow === w.value}
|
||||
className={`h-[26px] rounded-[6px] px-2.5 font-[family-name:var(--font-data)] text-[12.5px] transition-colors ${
|
||||
timeWindow === w.value
|
||||
? "bg-ds-fill font-semibold text-ds-text"
|
||||
: "text-ds-text-3 hover:text-ds-text"
|
||||
}`}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
|
||||
title={
|
||||
theme === "dark"
|
||||
? "Switch to light theme (redesigned screens only — other tabs stay dark for now)"
|
||||
: "Switch to dark theme"
|
||||
}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] border border-ds-line text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" aria-hidden="true" /> : <Moon className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-ds-line-strong bg-ds-surface-2 text-xs font-bold text-ds-text"
|
||||
title={user.email || user.sub}
|
||||
>
|
||||
{(user.name || user.email || user.sub).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="hidden flex-col text-left xl:flex">
|
||||
<span className="max-w-[120px] truncate text-xs font-medium leading-tight text-ds-text">
|
||||
{user.name || user.email?.split("@")[0] || user.sub}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
className="text-left font-[family-name:var(--font-data)] text-[10px] leading-none text-ds-text-3 hover:text-ds-critical"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => login()}
|
||||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-accent/30 bg-ds-accent-bg px-2.5 py-1.5 text-[13px] font-medium text-ds-accent"
|
||||
>
|
||||
<LogIn className="h-[15px] w-[15px]" aria-hidden="true" />
|
||||
Sign in
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBar
|
||||
+79
-3
@@ -55,9 +55,85 @@
|
||||
--color-outline: #849495;
|
||||
--color-outline-variant: #3b494b;
|
||||
|
||||
--font-headline: "Geist", sans-serif;
|
||||
--font-body: "Inter", sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--font-headline: "Bricolage Grotesque", "Geist", sans-serif;
|
||||
--font-body: "Instrument Sans", "Helvetica Neue", system-ui, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
* Design-spec tokens ("ds-*"): the neutral-chrome, colour-is-signal palette
|
||||
* from the Stackship observability console spec. Namespaced separately from
|
||||
* the legacy --color-* tokens above so screens can migrate one at a time
|
||||
* without the old MD3 palette and the new spec palette fighting over the
|
||||
* same variable names. New screens (icon rail, top bar, filter bar, triage
|
||||
* overview) are built on --color-ds-*; unconverted tabs keep using the
|
||||
* tokens above until they're redesigned too.
|
||||
*/
|
||||
:root {
|
||||
--ds-bg: #0d1014;
|
||||
--ds-chrome: #0b0e12;
|
||||
--ds-surface: #14181e;
|
||||
--ds-surface-2: #191e26;
|
||||
--ds-fill: #1d232b;
|
||||
--ds-line: #242b34;
|
||||
--ds-line-strong: #2a313b;
|
||||
--ds-text: #e8ecf2;
|
||||
--ds-text-2: #98a3b2;
|
||||
--ds-text-3: #8b96a6;
|
||||
--ds-critical: #ff6f5e;
|
||||
--ds-critical-bg: #2b1512;
|
||||
--ds-warn: #f0b24a;
|
||||
--ds-warn-bg: #2a2011;
|
||||
--ds-ok: #4fcf9a;
|
||||
--ds-accent: #7b8cff;
|
||||
--ds-accent-bg: #161b33;
|
||||
--ds-on-accent: var(--ds-chrome);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--ds-bg: #f4f2ed;
|
||||
--ds-chrome: #ffffff;
|
||||
--ds-surface: #ffffff;
|
||||
--ds-surface-2: #f7f5f0;
|
||||
--ds-fill: #edeae3;
|
||||
--ds-line: #e3e0d8;
|
||||
--ds-line-strong: #d6d2c8;
|
||||
--ds-text: #161a1f;
|
||||
--ds-text-2: #4f5964;
|
||||
--ds-text-3: #6b7480;
|
||||
--ds-critical: #c4402c;
|
||||
--ds-critical-bg: #fbe9e5;
|
||||
--ds-warn: #8a5d0e;
|
||||
--ds-warn-bg: #f9efda;
|
||||
--ds-ok: #1f7a56;
|
||||
--ds-accent: #3b4fc4;
|
||||
--ds-accent-bg: #e9ecfb;
|
||||
--ds-on-accent: #ffffff;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-ds-bg: var(--ds-bg);
|
||||
--color-ds-chrome: var(--ds-chrome);
|
||||
--color-ds-surface: var(--ds-surface);
|
||||
--color-ds-surface-2: var(--ds-surface-2);
|
||||
--color-ds-fill: var(--ds-fill);
|
||||
--color-ds-line: var(--ds-line);
|
||||
--color-ds-line-strong: var(--ds-line-strong);
|
||||
--color-ds-text: var(--ds-text);
|
||||
--color-ds-text-2: var(--ds-text-2);
|
||||
--color-ds-text-3: var(--ds-text-3);
|
||||
--color-ds-critical: var(--ds-critical);
|
||||
--color-ds-critical-bg: var(--ds-critical-bg);
|
||||
--color-ds-warn: var(--ds-warn);
|
||||
--color-ds-warn-bg: var(--ds-warn-bg);
|
||||
--color-ds-ok: var(--ds-ok);
|
||||
--color-ds-accent: var(--ds-accent);
|
||||
--color-ds-accent-bg: var(--ds-accent-bg);
|
||||
--color-ds-on-accent: var(--ds-on-accent);
|
||||
|
||||
--font-display: "Bricolage Grotesque", "Geist", sans-serif;
|
||||
--font-ui: "Instrument Sans", "Helvetica Neue", system-ui, sans-serif;
|
||||
--font-data: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -93,3 +93,100 @@ export function sampleContext(sample: IssueSample): string[] {
|
||||
if (sample.httpStatus) parts.push(String(sample.httpStatus))
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Severity presentation tone, derived from real triage state - never invented. */
|
||||
export type SeverityTone = "critical" | "warn" | "ok" | "muted"
|
||||
|
||||
export function issueSeverityTone(issue: Issue): SeverityTone {
|
||||
const status = issueStatusOf(issue)
|
||||
if (status === "resolved") return "ok"
|
||||
if (status === "ignored") return "muted"
|
||||
// Open frontend/span errors block a user-facing request; open log errors
|
||||
// are noteworthy but not necessarily user-visible, so they read as a
|
||||
// warning rather than critical.
|
||||
return issue.kind === "log_error" ? "warn" : "critical"
|
||||
}
|
||||
|
||||
export interface Correlate {
|
||||
key: string
|
||||
value: string
|
||||
share: number
|
||||
count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The single attribute value that best separates this issue's occurrences -
|
||||
* i.e. the most common (key, value) pair across its samples. Attributes that
|
||||
* are present on every sample the same way (say, a route that never varies)
|
||||
* are still a legitimate correlate; this only reports what's actually there.
|
||||
*/
|
||||
export function strongestCorrelate(issue: Issue): Correlate | null {
|
||||
const tally = new Map<string, { key: string; value: string; count: number }>()
|
||||
let total = 0
|
||||
|
||||
for (const sample of issue.samples) {
|
||||
const pairs: Array<[string, string | undefined]> = [
|
||||
["http.route", sample.httpRoute],
|
||||
["http.status", sample.httpStatus ? String(sample.httpStatus) : undefined],
|
||||
["release", sample.release],
|
||||
["environment", sample.environment],
|
||||
]
|
||||
let sampleCounted = false
|
||||
for (const [key, value] of pairs) {
|
||||
if (!value) continue
|
||||
sampleCounted = true
|
||||
const id = `${key}=${value}`
|
||||
const entry = tally.get(id) ?? { key, value, count: 0 }
|
||||
entry.count += 1
|
||||
tally.set(id, entry)
|
||||
}
|
||||
if (sampleCounted) total += 1
|
||||
}
|
||||
|
||||
if (total === 0) return null
|
||||
|
||||
let best: { key: string; value: string; count: number } | null = null
|
||||
for (const entry of tally.values()) {
|
||||
if (!best || entry.count > best.count) best = entry
|
||||
}
|
||||
if (!best) return null
|
||||
|
||||
return {
|
||||
key: best.key,
|
||||
value: best.value,
|
||||
count: best.count,
|
||||
total,
|
||||
share: best.count / total,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SparklineSeries {
|
||||
points: TrendPoint[]
|
||||
max: number
|
||||
/** Reference level (mean occurrence/hour) drawn as the expected band. */
|
||||
reference: number
|
||||
/** Index of the first point that breaches the reference band, if any. */
|
||||
breachIndex: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sparkline series for an issue's triage card: the observed
|
||||
* hourly trend plus a same-data reference line (the mean of the window) so a
|
||||
* breaching segment can be highlighted. This is a plain statistical baseline
|
||||
* over real occurrence counts, not a forecast or ML-derived expected band.
|
||||
*/
|
||||
export function sparklineSeries(issue: Issue, hours = 24): SparklineSeries {
|
||||
const points = trendPoints(issue, hours)
|
||||
const max = Math.max(...points.map((p) => p.count), 1)
|
||||
const mean = points.reduce((sum, p) => sum + p.count, 0) / points.length
|
||||
const threshold = Math.max(mean * 1.5, mean + 1)
|
||||
let breachIndex: number | null = null
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (points[i].count > threshold) {
|
||||
breachIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return { points, max, reference: mean, breachIndex }
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Real-user session grouping and Web Vitals rating, derived entirely from
|
||||
// what otel-rum.js actually sends: no click tracking, no checkout/outcome
|
||||
// signal, and no geo attribute exist in this pipeline, so none of those are
|
||||
// modeled here. See otel-rum.js for the ground truth of what's captured.
|
||||
import { getAttributeValue, getServiceName } from "./otel-utils"
|
||||
import { parseBreadcrumbs, type ParsedBreadcrumb } from "./issue-utils"
|
||||
import type { LogEntry } from "./types"
|
||||
|
||||
export interface VitalThreshold {
|
||||
good: number
|
||||
needsImprovement: number
|
||||
format: (v: number) => string
|
||||
}
|
||||
|
||||
// Google Core Web Vitals thresholds.
|
||||
export const VITAL_THRESHOLDS: Record<string, VitalThreshold> = {
|
||||
LCP: { good: 2500, needsImprovement: 4000, format: (v) => `${(v / 1000).toFixed(2)}s` },
|
||||
INP: { good: 200, needsImprovement: 500, format: (v) => `${Math.round(v)}ms` },
|
||||
CLS: { good: 0.1, needsImprovement: 0.25, format: (v) => (Math.round(v * 1000) / 1000).toFixed(3) },
|
||||
FCP: { good: 1800, needsImprovement: 3000, format: (v) => `${(v / 1000).toFixed(2)}s` },
|
||||
TTFB: { good: 800, needsImprovement: 1800, format: (v) => `${Math.round(v)}ms` },
|
||||
}
|
||||
|
||||
export type VitalRating = "good" | "needs-improvement" | "poor"
|
||||
|
||||
export function getVitalRating(name: string, value: number): VitalRating {
|
||||
const t = VITAL_THRESHOLDS[name]
|
||||
if (!t) return "good"
|
||||
if (value <= t.good) return "good"
|
||||
if (value <= t.needsImprovement) return "needs-improvement"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
export interface ParsedUserAgent {
|
||||
browser: string
|
||||
os: string
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
let browser = "Browser"
|
||||
let os = "OS"
|
||||
const 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 interface RumErrorEvent {
|
||||
timestamp: number // ms
|
||||
kind: "js" | "http"
|
||||
message: string
|
||||
exceptionType?: string
|
||||
stacktrace?: string
|
||||
httpMethod?: string
|
||||
httpUrl?: string
|
||||
httpStatus?: number
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export interface RumTimelineEntry {
|
||||
timestamp: number // ms
|
||||
category: "session-start" | "navigation" | "fetch" | "xhr" | "console.error" | "js-error" | "http-failure"
|
||||
message: string
|
||||
severe: boolean
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
id: string
|
||||
service: string
|
||||
firstSeen: number // ms
|
||||
lastSeen: number // ms
|
||||
userAgent: string
|
||||
device: ParsedUserAgent
|
||||
release?: string
|
||||
environment?: string
|
||||
urls: string[]
|
||||
vitals: Partial<Record<string, number>>
|
||||
errorCount: number
|
||||
httpFailureCount: number
|
||||
errors: RumErrorEvent[]
|
||||
timeline: RumTimelineEntry[]
|
||||
/** Sort key for "worst experience first" - a transparent sum of real signals, not a fabricated score. */
|
||||
severityScore: number
|
||||
}
|
||||
|
||||
interface RawFrontendRecord {
|
||||
timestamp: number
|
||||
service: string
|
||||
sessionId: string
|
||||
userAgent: string
|
||||
release?: string
|
||||
environment?: string
|
||||
path?: string
|
||||
vitalName?: string
|
||||
vitalValue?: number
|
||||
isError: boolean
|
||||
isHttpFailure: boolean
|
||||
exceptionType?: string
|
||||
exceptionMessage?: string
|
||||
stacktrace?: string
|
||||
httpMethod?: string
|
||||
httpUrl?: string
|
||||
httpStatus?: number
|
||||
traceId?: string
|
||||
body: string
|
||||
breadcrumbs: ParsedBreadcrumb[]
|
||||
}
|
||||
|
||||
function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] {
|
||||
const out: RawFrontendRecord[] = []
|
||||
|
||||
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) => {
|
||||
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"
|
||||
|
||||
const isFrontend =
|
||||
source === "frontend" || isRumScope || Boolean(vitalName) || Boolean(browserUrl) || Boolean(sessionId)
|
||||
if (!isFrontend) return
|
||||
|
||||
let path: string | undefined
|
||||
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 !== "") && !isHttp
|
||||
|
||||
const vitalValStr = getAttributeValue(attrs, "rum.value")
|
||||
const httpStatusStr = getAttributeValue(attrs, "http.response.status_code")
|
||||
|
||||
out.push({
|
||||
timestamp: r.timeUnixNano ? Number(BigInt(r.timeUnixNano) / 1_000_000n) : Date.now(),
|
||||
service,
|
||||
sessionId: sessionId || "anonymous",
|
||||
userAgent: getAttributeValue(attrs, "browser.userAgent"),
|
||||
release: getAttributeValue(attrs, "service.release") || undefined,
|
||||
environment: getAttributeValue(attrs, "service.environment") || undefined,
|
||||
path,
|
||||
vitalName: vitalName || undefined,
|
||||
vitalValue: vitalValStr ? parseFloat(vitalValStr) : undefined,
|
||||
isError: isErr,
|
||||
isHttpFailure: isHttp,
|
||||
exceptionType: excType || undefined,
|
||||
exceptionMessage: getAttributeValue(attrs, "exception.message") || undefined,
|
||||
stacktrace: getAttributeValue(attrs, "exception.stacktrace") || undefined,
|
||||
httpMethod: getAttributeValue(attrs, "http.request.method") || undefined,
|
||||
httpUrl: getAttributeValue(attrs, "url.full") || undefined,
|
||||
httpStatus: httpStatusStr ? parseInt(httpStatusStr, 10) : undefined,
|
||||
traceId: r.traceId || undefined,
|
||||
body: r.body?.stringValue || "",
|
||||
breadcrumbs: parseBreadcrumbs(getAttributeValue(attrs, "breadcrumbs")),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** A fetch/xhr breadcrumb whose message ends "-> error" or a 4xx/5xx status. */
|
||||
function isFailedRequestBreadcrumb(message: string): boolean {
|
||||
return /->\s*(error|[45]\d\d)$/.test(message)
|
||||
}
|
||||
|
||||
export function buildRumSessions(logs: LogEntry[]): RumSession[] {
|
||||
const records = extractFrontendRecords(logs)
|
||||
const bySession = new Map<string, RawFrontendRecord[]>()
|
||||
for (const r of records) {
|
||||
const list = bySession.get(r.sessionId) ?? []
|
||||
list.push(r)
|
||||
bySession.set(r.sessionId, list)
|
||||
}
|
||||
|
||||
const sessions: RumSession[] = []
|
||||
|
||||
for (const [id, list] of bySession) {
|
||||
list.sort((a, b) => a.timestamp - b.timestamp)
|
||||
const first = list[0]
|
||||
const last = list[list.length - 1]
|
||||
|
||||
const urls: string[] = []
|
||||
const seenUrls = new Set<string>()
|
||||
const vitals: Partial<Record<string, number>> = {}
|
||||
const errors: RumErrorEvent[] = []
|
||||
let errorCount = 0
|
||||
let httpFailureCount = 0
|
||||
|
||||
// The record carrying the fullest breadcrumb trail (otel-rum.js sends a
|
||||
// cumulative, capped snapshot with every record, so the longest one is
|
||||
// the most complete picture available for this session).
|
||||
let richestBreadcrumbs: ParsedBreadcrumb[] = []
|
||||
|
||||
for (const r of list) {
|
||||
if (r.path && !seenUrls.has(r.path)) {
|
||||
seenUrls.add(r.path)
|
||||
urls.push(r.path)
|
||||
}
|
||||
if (r.vitalName && r.vitalValue !== undefined) {
|
||||
vitals[r.vitalName.toUpperCase()] = r.vitalValue
|
||||
}
|
||||
if (r.isError) {
|
||||
errorCount += 1
|
||||
errors.push({
|
||||
timestamp: r.timestamp,
|
||||
kind: "js",
|
||||
message: r.exceptionMessage || r.body || r.exceptionType || "Unhandled error",
|
||||
exceptionType: r.exceptionType,
|
||||
stacktrace: r.stacktrace,
|
||||
traceId: r.traceId,
|
||||
})
|
||||
}
|
||||
if (r.isHttpFailure) {
|
||||
httpFailureCount += 1
|
||||
errors.push({
|
||||
timestamp: r.timestamp,
|
||||
kind: "http",
|
||||
message: r.body || "Request failed",
|
||||
httpMethod: r.httpMethod,
|
||||
httpUrl: r.httpUrl,
|
||||
httpStatus: r.httpStatus,
|
||||
traceId: r.traceId,
|
||||
})
|
||||
}
|
||||
if (r.breadcrumbs.length > richestBreadcrumbs.length) {
|
||||
richestBreadcrumbs = r.breadcrumbs
|
||||
}
|
||||
}
|
||||
|
||||
const timeline: RumTimelineEntry[] = []
|
||||
if (first.path || first.userAgent) {
|
||||
timeline.push({
|
||||
timestamp: first.timestamp,
|
||||
category: "session-start",
|
||||
message: first.path ? `Session started on ${first.path}` : "Session started",
|
||||
severe: false,
|
||||
})
|
||||
}
|
||||
for (const crumb of richestBreadcrumbs) {
|
||||
const category = crumb.category as RumTimelineEntry["category"]
|
||||
timeline.push({
|
||||
timestamp: crumb.t,
|
||||
category: category === "navigation" || category === "fetch" || category === "xhr" || category === "console.error"
|
||||
? category
|
||||
: "navigation",
|
||||
message: crumb.message,
|
||||
severe: crumb.category === "console.error" || isFailedRequestBreadcrumb(crumb.message),
|
||||
})
|
||||
}
|
||||
for (const err of errors) {
|
||||
timeline.push({
|
||||
timestamp: err.timestamp,
|
||||
category: err.kind === "js" ? "js-error" : "http-failure",
|
||||
message: err.message,
|
||||
severe: true,
|
||||
})
|
||||
}
|
||||
timeline.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
const vitalPenalty = (name: string) => {
|
||||
const v = vitals[name]
|
||||
if (v === undefined) return 0
|
||||
const rating = getVitalRating(name, v)
|
||||
return rating === "poor" ? 2 : rating === "needs-improvement" ? 1 : 0
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id,
|
||||
service: first.service,
|
||||
firstSeen: first.timestamp,
|
||||
lastSeen: last.timestamp,
|
||||
userAgent: last.userAgent,
|
||||
device: parseUserAgent(last.userAgent),
|
||||
release: last.release,
|
||||
environment: last.environment,
|
||||
urls,
|
||||
vitals,
|
||||
errorCount,
|
||||
httpFailureCount,
|
||||
errors: errors.sort((a, b) => b.timestamp - a.timestamp),
|
||||
timeline,
|
||||
severityScore:
|
||||
errorCount * 3 + httpFailureCount * 2 + vitalPenalty("INP") + vitalPenalty("LCP") + vitalPenalty("CLS"),
|
||||
})
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => b.severityScore - a.severityScore || b.lastSeen - a.lastSeen)
|
||||
}
|
||||
|
||||
export interface RumAggregates {
|
||||
totalSessions: number
|
||||
sessionsWithErrors: number
|
||||
totalJsErrors: number
|
||||
distinctJsErrorMessages: number
|
||||
totalHttpFailures: number
|
||||
inpP75: number | null
|
||||
}
|
||||
|
||||
export function aggregateRum(sessions: RumSession[]): RumAggregates {
|
||||
let sessionsWithErrors = 0
|
||||
let totalJsErrors = 0
|
||||
let totalHttpFailures = 0
|
||||
const distinctMessages = new Set<string>()
|
||||
const inpValues: number[] = []
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.errorCount > 0 || s.httpFailureCount > 0) sessionsWithErrors += 1
|
||||
totalJsErrors += s.errorCount
|
||||
totalHttpFailures += s.httpFailureCount
|
||||
for (const e of s.errors) {
|
||||
if (e.kind === "js") distinctMessages.add(e.message)
|
||||
}
|
||||
if (s.vitals.INP !== undefined) inpValues.push(s.vitals.INP)
|
||||
}
|
||||
|
||||
inpValues.sort((a, b) => a - b)
|
||||
const inpP75 =
|
||||
inpValues.length > 0 ? inpValues[Math.min(Math.floor(inpValues.length * 0.75), inpValues.length - 1)] : null
|
||||
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
sessionsWithErrors,
|
||||
totalJsErrors,
|
||||
distinctJsErrorMessages: distinctMessages.size,
|
||||
totalHttpFailures,
|
||||
inpP75,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Theme is a per-viewer convenience (which palette to render), not app
|
||||
// state, so it lives in localStorage rather than the observability context.
|
||||
// Only the redesigned screens (icon rail, top bar, filter bar, triage
|
||||
// overview) read the ds-* tokens this switches between - see index.css.
|
||||
const STORAGE_KEY = "otel.ui.theme"
|
||||
|
||||
export type ThemeMode = "dark" | "light"
|
||||
|
||||
export function getTheme(): ThemeMode {
|
||||
if (typeof document === "undefined") return "dark"
|
||||
return document.documentElement.dataset.theme === "light" ? "light" : "dark"
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemeMode): void {
|
||||
if (typeof document === "undefined") return
|
||||
if (theme === "light") {
|
||||
document.documentElement.dataset.theme = "light"
|
||||
} else {
|
||||
delete document.documentElement.dataset.theme
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
} catch {
|
||||
// Private browsing / blocked storage: the choice just won't persist.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user