This commit is contained in:
mats committed 2026-09-19 09:07:14 +02:00
1 parent 21f04d696b
commit 5792e9c037
16 files changed
+2101 -2109

No files matched your search

+11
View File
@@ -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
}
]
}
+17
View File
@@ -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
View File
@@ -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>
+165
View File
@@ -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
+536 -263
View File
@@ -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
-344
View File
@@ -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 &amp; 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>
);
};
+4 -4
View File
@@ -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"
-232
View File
@@ -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>&gt;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>
);
};
+128
View File
@@ -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
+163
View File
@@ -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
View File
@@ -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 {
+97
View File
@@ -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 }
}
+356
View File
@@ -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,
}
}
+26
View File
@@ -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.
}
}