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