From 1e9482ed216d6fe92ec0cc4e21bb685580f22ab9 Mon Sep 17 00:00:00 2001 From: matst80 Date: Sat, 19 Sep 2026 08:56:34 +0000 Subject: [PATCH] Unify otel-ui on ds-* theme; restore redesigned Issues view Restore IssuesVisualizer redesign reverted by merge 67ce419 and re-apply Needle triage/test additions. Migrate all remaining screens off the MD3 palette, raw Tailwind colors, gradients and Material Symbols. Remove the legacy theme block and fonts, drop the duplicate tab strip, rebuild TelemetryFilters on FilterBar, and document status in AGENTS.md. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 79 +- otel-ui/index.html | 12 +- otel-ui/src/App.tsx | 26 +- otel-ui/src/components/CommandAndQueryBar.tsx | 47 +- otel-ui/src/components/GaugeMeter.tsx | 96 +- otel-ui/src/components/IssuesVisualizer.tsx | 1081 ++++++++++------- otel-ui/src/components/MetricsVisualizer.tsx | 216 ++-- otel-ui/src/components/NeedleTestModal.tsx | 58 +- otel-ui/src/components/PatternsVisualizer.tsx | 34 +- otel-ui/src/components/RolloutsVisualizer.tsx | 26 +- otel-ui/src/components/SearchAndWatcher.tsx | 114 +- otel-ui/src/components/ServiceGraph.tsx | 78 +- otel-ui/src/components/SetupPanel.tsx | 36 +- .../src/components/StructuredLogTerminal.tsx | 189 ++- otel-ui/src/components/Tabs.tsx | 199 +-- otel-ui/src/components/TelemetryFilters.tsx | 146 +-- otel-ui/src/components/TraceVisualization.tsx | 410 +++---- otel-ui/src/components/TrendsVisualizer.tsx | 82 +- otel-ui/src/components/ViewErrorBoundary.tsx | 14 +- otel-ui/src/components/WaveformHistogram.tsx | 93 +- otel-ui/src/index.css | 97 +- otel-ui/src/lib/issue-utils.ts | 6 +- otel-ui/src/lib/otel-utils.ts | 92 +- 23 files changed, 1583 insertions(+), 1648 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac74f65..c40611d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,15 @@ The theme toggle lives in `TopBar.tsx`, persists to `localStorage` (`otel.ui.theme`), and is applied before first paint by an inline script in `index.html` so there's no flash of the wrong theme. -The repo still has a second, older color system - the MD3-style -`--color-surface-container-*` / `--color-on-surface` / `--color-primary-container` -tokens, also in `index.css` - left over from before this redesign. That system -is **dead**, not a design choice: it exists only because not every screen has -been migrated yet (see the checklist below). Never add new usages of it. Once -the checklist is empty, delete that whole legacy `@theme` block plus the old -Geist/Inter/JetBrains/Material-Symbols font `` entries in `index.html`, -so exactly one design system remains in the codebase. +The old MD3-style `--color-surface-container-*` / `--color-on-surface` tokens, +the Material Symbols icon font and the Geist/Inter/JetBrains fonts have been +**deleted**. Never reintroduce them, raw Tailwind palette colors +(`slate-*`, `sky-*`, `rose-*`, `white/10`...), gradients, or shadows. Use +`lucide-react` for icons. + +Documented exceptions (data-viz only, never chrome): per-service span colors +in the trace waterfall (`getSpanColor` in `otel-utils.ts`) and the muted +chart-series hues for metric lines/pods (`POD_COLORS`). ### Token reference @@ -130,52 +131,36 @@ remaining screens: `getComputedStyle`, trust the computed style, front the tab, and re-check before concluding there's a real bug. -## Migration checklist +## Migration status -Converted to `ds-*` already: `App.tsx` (shell), `IconRail.tsx`, `TopBar.tsx`, -`FilterBar.tsx`, `IssuesVisualizer.tsx` (Issues / triage overview, tab0), -`RumSessionsVisualizer.tsx` (RUM sessions, tab7). +Whole UI is on `ds-*` (one theme, light + dark). The nav strip in `Tabs.tsx` +is gone - `IconRail` is the only navigation; `Tabs.tsx` just renders the +active screen. `TelemetryFilters` is built on `FilterBar`. The Material +Symbols icons were replaced with lucide. -Everything below still renders with the legacy MD3 dark-only palette -(`text-on-surface`, `bg-surface-container-*`, `bg-primary-container`, -gradient badges, the Material Symbols icon font) and needs the same -treatment. Sizes are current line counts, biggest first: +Lessons from getting here: -| File | Lines | Tab / role | Notes | -|---|---|---|---| -| `TraceVisualization.tsx` | 1866 | Traces & Spans (tab1) | Waterfall + span detail; closest existing screen to the design spec's `trace-waterfall.html` reference | -| `MetricsVisualizer.tsx` | 1107 | Metrics & APM (tab2) | Imports `GaugeMeter.tsx` | -| `StructuredLogTerminal.tsx` | 582 | Live Log Stream (tab3) | Uses Material Symbols icons | -| `Tabs.tsx` (shell) | 513 | horizontal tab strip rendered above every screen | Gradient active-tab underline, slate colors; decide whether this strip is still needed now that `IconRail` is the primary nav, or whether it should just lose the gradient/slate styling | -| `SearchAndWatcher.tsx` | 504 | Archive & Trap (tab6) | | -| `ServiceGraph.tsx` | 495 | used by `TraceVisualization.tsx` | migrate alongside its caller | -| `WaveformHistogram.tsx` | 385 | shown above every non-Issues/RUM tab's content | Uses Material Symbols icons; see the shared-chrome note below | -| `GaugeMeter.tsx` | 372 | used by `MetricsVisualizer.tsx` | migrate alongside its caller | -| `TrendsVisualizer.tsx` | 323 | Historical Trends (tab5, only when ClickHouse stats are configured) | | -| `CommandAndQueryBar.tsx` | 251 | shown above every non-Issues/RUM tab's content | see the shared-chrome note below | -| `TelemetryFilters.tsx` | 124 | conditionally shown above `Tabs.tsx` content | service multiselect + attribute exclusions; candidate to rebuild on top of `FilterBar.tsx` instead of restyling in place | -| `SetupPanel.tsx` | 112 | Setup & Agent API (tab4) | | -| `PatternsVisualizer.tsx` | 76 | Similarity Patterns (tab8) | | -| `ViewErrorBoundary.tsx` | 62 | error fallback UI, wraps every tab's content | small, easy pass | -| `RolloutsVisualizer.tsx` | 48 | Cluster Rollouts (tab9) | | -| `AuthCallbackView` (inside `App.tsx`) | ~40 | OIDC callback screen | still 100% old tokens even though the rest of `App.tsx` is migrated | - -**Shared chrome note:** `CommandAndQueryBar.tsx` (anomaly banner, filter -status strip, NDJSON export) and `WaveformHistogram.tsx` (volume/error -waveform with click-to-focus) are currently skipped entirely on the Issues -tab (`isTriageOverview` branch in `App.tsx`) because their old styling would -clash with the new `FilterBar`. When migrating the remaining tabs, decide -per screen whether that real functionality gets folded into the screen's own -`FilterBar`/content area (as happened for Issues and RUM sessions), or stays -as shared chrome above the remaining tabs, restyled to `ds-*` tokens. +- **Merges can silently revert a redesign.** `67ce419` reverted + `IssuesVisualizer.tsx` to the old styling (a parallel branch had edited the + same file). After any merge touching `otel-ui/src`, run the greps below - + they must return nothing. +- **A stale `otel-ui/dist` or un-redeployed image hides changes.** The Go + server serves `otel-ui/dist`, else `./dist`, else the legacy root + `index.html`. Rebuild (`npm --prefix otel-ui run build`) before concluding a + screen isn't wired. +- Still to polish (functional, but not yet re-laid-out to the screen + pattern): `TraceVisualization`, `MetricsVisualizer`, `StructuredLogTerminal`, + `SearchAndWatcher`, `TrendsVisualizer`, `SetupPanel`. They use `ds-*` + colors via a mechanical token swap but keep their old layout/type scale, use + `onClick` on non-buttons in places, and `CommandAndQueryBar` / + `WaveformHistogram` still sit above every non-Issues/RUM tab. Restructure + them onto `FilterBar` + the card/aside pattern when touching them. ### Finding old-pattern usages ```sh grep -rlE "surface-container|text-on-surface|primary-container|bg-error-container|material-symbols-outlined" otel-ui/src -grep -rlE "from-[a-z]+-[0-9]+ via-[a-z]+-[0-9]+|getServicePalette" otel-ui/src +grep -rnE "(bg|text|border|ring|from|to|via)-(slate|zinc|gray|sky|cyan|blue|violet|indigo|purple|emerald|teal|rose|red|amber|orange)-[0-9]|text-white|border-white|bg-(white|black)/" otel-ui/src ``` -A file with zero hits and zero `ds-*` usage either has no meaningful styling -of its own (a pure hook/context) or hasn't been looked at yet - check the -checklist above. +Both must print nothing. diff --git a/otel-ui/index.html b/otel-ui/index.html index 93b0a7e..d2eda5c 100644 --- a/otel-ui/index.html +++ b/otel-ui/index.html @@ -7,10 +7,6 @@ HyperScope OTel — Distributed Telemetry - @@ -19,17 +15,15 @@ rel="stylesheet" /> - +
diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx index ff80844..3205344 100644 --- a/otel-ui/src/App.tsx +++ b/otel-ui/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { CloudCog } from "lucide-react"; +import { AlertCircle, CloudCog, Lock } from "lucide-react"; import { useObservabilityData } from "./hooks/useObservabilityData"; import { IconRail } from "./components/layout/IconRail"; import { TopBar } from "./components/layout/TopBar"; @@ -27,37 +27,37 @@ function AuthCallbackView() { }, []); return ( -
-
+
+
{error ? (
-
- error +
+
-

Authentication Failed

-

{error}

+

Authentication failed

+

{error}

) : (
-
- lock +
+
-

Completing sign in

-

Exchanging authorization code with OIDC provider…

+

Completing sign in

+

Exchanging authorization code with OIDC provider…

)} -
+
); } diff --git a/otel-ui/src/components/CommandAndQueryBar.tsx b/otel-ui/src/components/CommandAndQueryBar.tsx index b0bfb98..37278b8 100644 --- a/otel-ui/src/components/CommandAndQueryBar.tsx +++ b/otel-ui/src/components/CommandAndQueryBar.tsx @@ -1,3 +1,4 @@ +import { AlertTriangle, Download } from "lucide-react" import React from "react"; import { useObservabilityData } from "../hooks/useObservabilityData"; import { useIssues } from "../hooks/useIssues"; @@ -101,25 +102,25 @@ export const CommandAndQueryBar: React.FC = () => {
{/* Real Anomaly Radar Banner */} {(errorStats.errorCount > 0 || topOpenIssue) && ( -
+
-
- warning +
+
- + Anomaly Radar: - + {errorStats.errorCount > 0 ? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})` : `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`}
{topOpenIssue && ( - - Top Issue: {topOpenIssue.title} (Service: {topOpenIssue.service}, ×{topOpenIssue.count}) + + Top Issue: {topOpenIssue.title} (Service: {topOpenIssue.service}, ×{topOpenIssue.count}) )}
@@ -129,7 +130,7 @@ export const CommandAndQueryBar: React.FC = () => { @@ -137,7 +138,7 @@ export const CommandAndQueryBar: React.FC = () => { @@ -147,13 +148,13 @@ export const CommandAndQueryBar: React.FC = () => { )} {/* Filter and Telemetry Status Bar */} -
+
- + Active Context: - + {totalFilteredEvents} Signals ({timeWindow === "all" ? "All Time" : `Last ${timeWindow}`}) @@ -161,13 +162,13 @@ export const CommandAndQueryBar: React.FC = () => { {Array.from(selectedServices).map((service) => ( service:{service} @@ -176,12 +177,12 @@ export const CommandAndQueryBar: React.FC = () => { {/* Errors Only Pill */} {showErrorsOnly && ( - + Errors Only @@ -190,12 +191,12 @@ export const CommandAndQueryBar: React.FC = () => { {/* Slow Spans Pill */} {minDurationMs > 0 && ( - + Latency >{minDurationMs}ms @@ -204,12 +205,12 @@ export const CommandAndQueryBar: React.FC = () => { {/* Search Query Pill */} {searchQuery && ( - + query: "{searchQuery}" @@ -224,7 +225,7 @@ export const CommandAndQueryBar: React.FC = () => { setMinDurationMs(0); setTimeWindow("all"); }} - className="text-outline hover:text-white font-mono text-xs underline ml-1" + className="text-ds-text-3 hover:text-ds-text font-mono text-xs underline ml-1" > Clear all @@ -236,10 +237,10 @@ export const CommandAndQueryBar: React.FC = () => {
diff --git a/otel-ui/src/components/GaugeMeter.tsx b/otel-ui/src/components/GaugeMeter.tsx index 3ee2ea7..f33c1f5 100644 --- a/otel-ui/src/components/GaugeMeter.tsx +++ b/otel-ui/src/components/GaugeMeter.tsx @@ -116,36 +116,36 @@ export const GaugeMeter = ({ const statusColor = useMemo(() => { if (percent >= dangerThreshold) { return { - text: "text-rose-400", - stroke: "#f43f5e", - gradientStart: "#fb7185", - gradientEnd: "#e11d48", - bg: "bg-rose-500/10", - border: "border-rose-500/30", - glow: "rgba(244, 63, 94, 0.35)", + text: "text-ds-critical", + stroke: "var(--ds-critical)", + gradientStart: "var(--ds-critical)", + gradientEnd: "var(--ds-critical)", + bg: "bg-ds-critical-bg", + border: "border-ds-critical/30", + glow: "color-mix(in srgb, var(--ds-critical) 35%, transparent)", label: "Critical", }; } if (percent >= warnThreshold) { return { - text: "text-amber-400", - stroke: "#f59e0b", - gradientStart: "#fbbf24", - gradientEnd: "#d97706", - bg: "bg-amber-500/10", - border: "border-amber-500/30", - glow: "rgba(245, 158, 11, 0.35)", + text: "text-ds-warn", + stroke: "var(--ds-warn)", + gradientStart: "var(--ds-warn)", + gradientEnd: "var(--ds-warn)", + bg: "bg-ds-warn-bg", + border: "border-ds-warn/30", + glow: "color-mix(in srgb, var(--ds-warn) 35%, transparent)", label: "Warning", }; } return { - text: "text-cyan-400", - stroke: "#06b6d4", - gradientStart: "#38bdf8", - gradientEnd: "#0284c7", - bg: "bg-cyan-500/10", - border: "border-cyan-500/30", - glow: "rgba(6, 182, 212, 0.35)", + text: "text-ds-accent", + stroke: "var(--ds-accent)", + gradientStart: "var(--ds-accent)", + gradientEnd: "var(--ds-accent)", + bg: "bg-ds-accent-bg", + border: "border-ds-accent/30", + glow: "color-mix(in srgb, var(--ds-accent) 35%, transparent)", label: "Nominal", }; }, [percent, warnThreshold, dangerThreshold]); @@ -155,9 +155,9 @@ export const GaugeMeter = ({ if (!showTrend || history.length < 2) return null; const prev = history[history.length - 2]; const diff = value - prev; - if (Math.abs(diff) < 0.0001) return { direction: "flat", text: "steady", color: "text-slate-500" }; - if (diff > 0) return { direction: "up", text: `+${formatNumber(diff)}`, color: "text-rose-400" }; - return { direction: "down", text: `${formatNumber(diff)}`, color: "text-emerald-400" }; + if (Math.abs(diff) < 0.0001) return { direction: "flat", text: "steady", color: "text-ds-text-3" }; + if (diff > 0) return { direction: "up", text: `+${formatNumber(diff)}`, color: "text-ds-critical" }; + return { direction: "down", text: `${formatNumber(diff)}`, color: "text-ds-ok" }; }, [value, history, showTrend]); // Dimensions @@ -185,8 +185,8 @@ export const GaugeMeter = ({ ); return ( -
-
+
+
{service && ( @@ -200,15 +200,15 @@ export const GaugeMeter = ({ )} {podCount !== undefined && podCount > 1 && ( - ∑ + ∑ {podCount} pods )}
- + {name}
@@ -233,7 +233,7 @@ export const GaugeMeter = ({ @@ -249,7 +249,7 @@ export const GaugeMeter = ({ y1={inner.y} x2={outer.x} y2={outer.y} - stroke="rgba(255, 255, 255, 0.2)" + stroke="var(--ds-line-strong)" strokeWidth={1.5} /> ); @@ -275,7 +275,7 @@ export const GaugeMeter = ({ cx={tip.x} cy={tip.y} r={strokeWidth * 0.6} - fill="#ffffff" + fill="var(--ds-text)" stroke={statusColor.stroke} strokeWidth={2} className="transition-all duration-300" @@ -287,15 +287,15 @@ export const GaugeMeter = ({ {/* Center Readout */}
{soloPod ? ( - + {soloPod} ) : aggLabel ? ( - + {aggLabel} ) : podCount !== undefined && podCount > 1 ? ( - + {isSum ? "Sum (∑)" : "Avg (μ)"} ) : null} @@ -304,20 +304,20 @@ export const GaugeMeter = ({ {formatNumber(value)} {unit && ( - + {unit} )}
{podCount !== undefined && podCount > 1 ? ( {isSum ? `~${formatNumber(value / podCount)}/pod` : `${podCount} pods avg`} ) : subtitle ? ( - + {subtitle} ) : null} @@ -325,9 +325,9 @@ export const GaugeMeter = ({
{/* Footer range indicators & trend */} -
+
- min {formatNumber(min)} + min {formatNumber(min)} {trend && ( @@ -338,30 +338,30 @@ export const GaugeMeter = ({ )} {avg !== undefined && ( - - avg {formatNumber(avg)} + + avg {formatNumber(avg)} )} - max {formatNumber(max)} + max {formatNumber(max)}
{/* Pod Breakdown pills if available */} {podSummaries && podSummaries.length > 1 && ( -
+
{podSummaries.slice(0, 3).map((ps) => (
- {ps.pod} - {formatNumber(ps.value)} + {ps.pod} + {formatNumber(ps.value)}
))} {podSummaries.length > 3 && ( - + +{podSummaries.length - 3} )} diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx index 73bd36d..c90b261 100644 --- a/otel-ui/src/components/IssuesVisualizer.tsx +++ b/otel-ui/src/components/IssuesVisualizer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react" +import { useMemo, useState, type ReactNode } from "react" import { Activity, AlertOctagon, @@ -19,25 +19,27 @@ import { Loader2, X, } from "lucide-react" -import { NeedleTestModal } from "./NeedleTestModal" import { useObservabilityData } from "../hooks/useObservabilityData" import { authFetch } from "../lib/auth" import { formatRelativeTime } from "../lib/timeUtils" -import { getServicePalette } from "../lib/otel-utils" import { - issueStatusMeta, + issueSeverityTone, issueStatusOf, latestBreadcrumbs, latestStacktrace, sampleContext, - trendPoints, + sparklineSeries, + strongestCorrelate, + type SeverityTone, } from "../lib/issue-utils" +import { NeedleTestModal } from "./NeedleTestModal" +import { FilterBar, type FilterChip } from "./FilterBar" import type { Issue, IssueSample } from "../lib/types" const kindMeta: Record = { - span_error: { label: "Trace Error", Icon: Activity }, - log_error: { label: "Log Error", Icon: ScrollText }, - frontend_error: { label: "Frontend Error", Icon: Globe }, + span_error: { label: "Trace error", Icon: Activity }, + log_error: { label: "Log error", Icon: ScrollText }, + frontend_error: { label: "Frontend error", Icon: Globe }, } type StatusFilter = "all" | "open" | "resolved" | "ignored" @@ -64,66 +66,134 @@ interface DetectorTestResult { error?: string } -const statusFilters: { value: StatusFilter; label: string }[] = [ +interface NeedleResult { + action?: string + tool?: string + confidence?: number + reasoning?: string + latency_ms?: number + arguments?: Record + error?: string +} + +const statusTabs: { value: StatusFilter; label: string }[] = [ { value: "all", label: "All" }, { value: "open", label: "Open" }, { value: "resolved", label: "Resolved" }, { value: "ignored", label: "Ignored" }, ] -const kindFilters: { value: KindFilter; label: string; Icon: typeof Activity }[] = [ - { value: "all", label: "All kinds", Icon: Bug }, - { value: "span_error", label: "Traces", Icon: Activity }, - { value: "log_error", label: "Logs", Icon: ScrollText }, - { value: "frontend_error", label: "Frontend", Icon: Globe }, +const scopeOptions: { value: KindFilter; label: string }[] = [ + { value: "all", label: "All kinds" }, + { value: "span_error", label: "Traces" }, + { value: "log_error", label: "Logs" }, + { value: "frontend_error", label: "Frontend" }, ] -// Lightweight inline-SVG sparkline of the issue's hourly occurrence trend. -// Deliberately not recharts: issue lists can hold dozens of cards and each -// recharts instance carries meaningful render cost. -const TrendSparkline = ({ issue }: { issue: Issue }) => { - const points = trendPoints(issue, 24) - const max = Math.max(...points.map((p) => p.count), 1) - const width = 96 - const height = 24 +const toneDot: Record = { + critical: "bg-ds-critical", + warn: "bg-ds-warn", + ok: "bg-ds-ok", + muted: "bg-ds-text-3", +} + +const tonePill: Record = { + critical: "bg-ds-critical-bg text-ds-critical", + warn: "bg-ds-warn-bg text-ds-warn", + ok: "bg-ds-ok/10 text-ds-ok", + muted: "bg-ds-fill text-ds-text-3", +} + +const toneLabel: Record = { + critical: "Open", + warn: "Open", + ok: "Resolved", + muted: "Ignored", +} + +const toneLine: Record = { + critical: "border-l-ds-critical", + warn: "border-l-ds-warn", + ok: "border-l-ds-ok", + muted: "border-l-ds-line-strong", +} + +/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */ +const IssueSparkline = ({ issue }: { issue: Issue }) => { + const { points, max, reference, breachIndex } = sparklineSeries(issue, 24) + const width = 100 + const height = 100 const step = width / (points.length - 1) - const path = points - .map((p, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - (p.count / max) * (height - 2) - 1).toFixed(1)}`) - .join(" ") - const gradientId = `sparkFill-${issue.id}` + const toY = (v: number) => height - (v / max) * (height - 8) - 4 + + const observedPoints = points.map((p, i) => `${(i * step).toFixed(1)},${toY(p.count).toFixed(1)}`).join(" ") + + const hasBreach = breachIndex !== null + const breachPath = hasBreach + ? points + .slice(breachIndex) + .map((p, i) => `${i === 0 ? "M" : "L"}${((breachIndex + i) * step).toFixed(1)},${toY(p.count).toFixed(1)}`) + .join(" ") + : "" + const detectX = hasBreach ? breachIndex * step : null + const refY = toY(reference) + const bandHeight = Math.max(height - refY - 4, 6) + + const description = hasBreach + ? `Hourly occurrence count over the last 24 hours, climbing above the period average roughly ${points.length - breachIndex} hours ago and still elevated` + : `Hourly occurrence count over the last 24 hours, staying within the period average` return ( - - - - - - - - - - - - + + + {hasBreach && ( + + )} + {hasBreach && detectX !== null && ( + + )} + ) } - -const StatusBadge = ({ issue }: { issue: Issue }) => { - const status = issueStatusOf(issue) - const meta = issueStatusMeta[status] - return ( - - {meta.label} +const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => ( +
+ {label} + + {value} - ) -} +
+) const StacktraceView = ({ stacktrace }: { stacktrace: string }) => ( -
+  
     {stacktrace}
   
) @@ -133,20 +203,20 @@ const BreadcrumbsView = ({ issue }: { issue: Issue }) => { if (!crumbs.length) return null return (
-

- +

+

-
+
{crumbs.map((crumb, idx) => ( -
- +
+ {new Date(crumb.t).toLocaleTimeString([], { hour12: false })} - + {crumb.category} - {crumb.message.slice(0, 200)} + {crumb.message.slice(0, 200)}
))}
@@ -158,7 +228,7 @@ const SampleContextChips = ({ sample }: { sample: IssueSample }) => { const ctx = sampleContext(sample) if (!ctx.length) return null return ( - + {ctx.join(" · ")} ) @@ -187,14 +257,15 @@ const StatusActions = ({ {actions.map(({ to, label, Icon }) => ( ))} @@ -213,31 +284,10 @@ const IssueCard = ({ }) => { const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData() const [expanded, setExpanded] = useState(false) - - const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug } - const isFrontend = issue.kind === "frontend_error" - const badgeGradient = isFrontend - ? "from-violet-500 via-sky-500 to-cyan-400" - : "from-rose-500 via-red-500 to-orange-400" - const palette = getServicePalette(issue.service) - const latestSample = issue.samples[issue.samples.length - 1] - const stacktrace = latestStacktrace(issue) - const hasMappedStacktrace = issue.samples.some((sample) => Boolean(sample.resolvedStacktrace)) - const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus) - - const [needleResult, setNeedleResult] = useState<{ - action?: string - tool?: string - confidence?: number - reasoning?: string - latency_ms?: number - arguments?: Record - error?: string - } | null>(null) + const [needleResult, setNeedleResult] = useState(null) const [needleLoading, setNeedleLoading] = useState(false) - const handleNeedleTriage = async (e: React.MouseEvent) => { - e.stopPropagation() + const handleNeedleTriage = async () => { setNeedleLoading(true) try { const response = await authFetch("/api/needle/classify", { @@ -245,8 +295,7 @@ const IssueCard = ({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ issue_id: issue.id }), }) - const data = await response.json() - setNeedleResult(data) + setNeedleResult(await response.json()) } catch (err) { setNeedleResult({ error: err instanceof Error ? err.message : String(err) }) } finally { @@ -254,66 +303,125 @@ const IssueCard = ({ } } + const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug } + const tone = issueSeverityTone(issue) + const latestSample = issue.samples[issue.samples.length - 1] + const stacktrace = latestStacktrace(issue) + const hasMappedStacktrace = issue.samples.some((sample) => Boolean(sample.resolvedStacktrace)) + const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus) + const correlate = strongestCorrelate(issue) + const viewTrace = (traceId: string, spanId?: string) => { setActiveTraceId(traceId) if (spanId) setActiveSpanId(spanId) setActiveTab("tab1") } + const metaParts = [`service=${issue.service}`] + if (contextSample?.httpMethod && contextSample?.httpRoute) { + metaParts.push(`route=${contextSample.httpMethod} ${contextSample.httpRoute}`) + } else if (contextSample?.httpRoute) { + metaParts.push(`route=${contextSample.httpRoute}`) + } + if (issue.affectedServices.length > 1) { + metaParts.push(`affects=${issue.affectedServices.length} services`) + } + return ( -
-
+
+
+ -
setExpanded((prev) => !prev)} - className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4" - > - - - {meta.label} - +
+ {latestSample?.traceId && ( + + )} + + +
+
- - {issue.service} - +
+ +
- - {issue.title} - - - - - - ×{issue.count} - - - - - - last seen {formatRelativeTime(issue.lastSeen / 1e6)} - - - {expanded ? : } +
+ + + +
+ Strongest correlate + {correlate ? ( + + {correlate.key}={correlate.value}{" "} + + {Math.round(correlate.share * 100)}% of {correlate.total} occurrences + + + ) : ( + NO SIGNAL + )} +
{expanded && ( -
+
-
- First seen {formatRelativeTime(issue.firstSeen / 1e6)} - {issue.affectedServices.length > 1 && ( - Affects: {issue.affectedServices.join(", ")} - )} +
{issue.status === "resolved" && issue.resolvedAt ? ( Resolved {formatRelativeTime(issue.resolvedAt / 1e6)} ) : null} + {issue.affectedServices.length > 1 && ( + Affects: {issue.affectedServices.join(", ")} + )}
-
{stacktrace && (
-

+

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

@@ -322,10 +430,10 @@ const IssueCard = ({ -
+
{(contextSample?.userAgent || latestSample?.userAgent) && ( - - + + )} @@ -338,16 +446,67 @@ const IssueCard = ({
-

+ + {needleResult && ( +

+
+ + Needle 3 action + + {needleResult.action || needleResult.tool || "Processed"} + + + {needleResult.confidence !== undefined && ( + + confidence{" "} + = 0.6 ? "text-ds-ok" : "text-ds-warn"}> + {(needleResult.confidence * 100).toFixed(0)}% + + {needleResult.latency_ms ? ` (${needleResult.latency_ms}ms)` : ""} + + )} +
+ {needleResult.reasoning &&

“{needleResult.reasoning}”

} + {needleResult.arguments && Object.keys(needleResult.arguments).length > 0 && ( +
+ {Object.entries(needleResult.arguments).map(([k, v]) => ( + + {k}: {String(v)} + + ))} +
+ )} + {needleResult.error && ( +

{needleResult.error}

+ )} +
+ )} +
+ +
+

Recent occurrences ({issue.samples.length})

{[...issue.samples].reverse().map((sample, idx) => (
- + {new Date(sample.timestamp / 1e6).toLocaleTimeString([], { hour12: false, hour: "2-digit", @@ -356,123 +515,124 @@ const IssueCard = ({ })} - {sample.message} + {sample.message} {sample.url && ( e.stopPropagation()} - className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500/10 border border-violet-400/20 px-2.5 py-0.5 text-[10px] font-bold text-violet-300 hover:bg-violet-500/20 hover:border-violet-400/30 transition-all max-w-[220px] truncate" + className="flex max-w-[220px] shrink-0 items-center gap-1 truncate rounded-full border border-ds-accent/30 bg-ds-accent-bg px-2.5 py-0.5 text-[10.5px] font-bold text-ds-accent" title={sample.url} > - + )} {sample.traceId && ( )}
))}
- -
- - - {latestSample?.traceId && ( - - )} -
- - {needleResult && ( -
-
-
- - - Needle 3 Action: - - - {needleResult.action || needleResult.tool || "Processed"} - -
- {needleResult.confidence !== undefined && ( - - Confidence:{" "} - = 0.6 ? "text-emerald-400" : "text-amber-400"}`}> - {(needleResult.confidence * 100).toFixed(0)}% - - {needleResult.latency_ms && ( - ({needleResult.latency_ms}ms) - )} - - )} -
- - {needleResult.reasoning && ( -

- “{needleResult.reasoning}” -

- )} - - {needleResult.arguments && Object.keys(needleResult.arguments).length > 0 && ( -
- - Extracted Arguments: - -
- {Object.entries(needleResult.arguments).map(([k, v]) => ( - - {k}: {String(v)} - - ))} -
-
- )} - - {needleResult.error && ( -

{needleResult.error}

- )} -
- )}
)} -
+
+ ) +} + +const ServiceDeviationPanel = () => { + const { uniqueServices, serviceHealth } = useObservabilityData() + + const rows = useMemo(() => { + return uniqueServices + .map((name) => { + const health = serviceHealth[name] + const total = health?.totalSignals ?? 0 + const errors = health?.errorCount ?? 0 + return { name, total, errors, rate: total > 0 ? errors / total : 0 } + }) + .sort((a, b) => b.errors - a.errors || b.rate - a.rate) + .slice(0, 8) + }, [uniqueServices, serviceHealth]) + + return ( +
+
+

Services by deviation

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

Listening for service signals…

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

Issue status

+ +
    + {segments.map((seg) => ( +
  • +
  • + ))} +
+
) } @@ -493,7 +653,8 @@ export const IssuesVisualizer = ({ onStatusChange, statusBusy, }: IssuesVisualizerProps) => { - const { selectedServices, searchQuery } = useObservabilityData() + const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } = + useObservabilityData() const [statusFilter, setStatusFilter] = useState("all") const [kindFilter, setKindFilter] = useState("all") const [needleModalOpen, setNeedleModalOpen] = useState(false) @@ -519,25 +680,10 @@ export const IssuesVisualizer = ({ } } - const runDetectorTest = async () => { - setDetectorTestLoading(true) - setDetectorTest(null) - try { - const response = await authFetch("/api/ml/test-spike", { method: "POST" }) - const data = (await response.json()) as DetectorTestResult - if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`) - setDetectorTest(data) - } catch (err) { - setDetectorTest({ error: err instanceof Error ? err.message : String(err) }) - } finally { - setDetectorTestLoading(false) - } - } - const spamIPSet = useMemo(() => new Set(spamIPs.map((entry) => entry.ip)), [spamIPs]) const statusCounts = useMemo(() => { - const counts = { all: issues.length, open: 0, resolved: 0, ignored: 0 } + const counts: Record = { all: issues.length, open: 0, resolved: 0, ignored: 0 } issues.forEach((issue) => { counts[issueStatusOf(issue)] += 1 }) @@ -575,200 +721,313 @@ export const IssuesVisualizer = ({ }) }, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet]) + const { primaryIssues, secondaryIssues } = useMemo(() => { + const sorted = [...filteredIssues].sort((a, b) => b.count - a.count) + if (statusFilter !== "all") return { primaryIssues: sorted, secondaryIssues: [] as Issue[] } + return { + primaryIssues: sorted.filter((i) => issueStatusOf(i) === "open"), + secondaryIssues: sorted.filter((i) => issueStatusOf(i) !== "open"), + } + }, [filteredIssues, statusFilter]) + + const [showSecondary, setShowSecondary] = useState(false) + + const runDetectorTest = async () => { + setDetectorTestLoading(true) + setDetectorTest(null) + try { + const response = await authFetch("/api/ml/test-spike", { method: "POST" }) + const data = (await response.json()) as DetectorTestResult + if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`) + setDetectorTest(data) + } catch (err) { + setDetectorTest({ error: err instanceof Error ? err.message : String(err) }) + } finally { + setDetectorTestLoading(false) + } + } + + const activeFilterCount = + selectedServices.size + (statusFilter !== "all" ? 1 : 0) + (excludeSpamIPs ? 1 : 0) + (searchQuery ? 1 : 0) + + const chips: FilterChip[] = [ + ...Array.from(selectedServices).map((service) => ({ + id: `service-${service}`, + filterKey: "service", + value: service, + tone: "neutral" as const, + onRemove: () => toggleService(service), + })), + ...(statusFilter !== "all" + ? [ + { + id: "status", + filterKey: "status", + value: statusFilter, + tone: statusFilter === "resolved" ? ("ok" as const) : ("neutral" as const), + onRemove: () => setStatusFilter("all"), + }, + ] + : []), + ...(excludeSpamIPs + ? [ + { + id: "spam-ips", + filterKey: "spam-ips", + value: "hidden", + tone: "neutral" as const, + onRemove: () => setExcludeSpamIPs(false), + }, + ] + : []), + ...(searchQuery + ? [ + { + id: "query", + filterKey: "query", + value: searchQuery, + tone: "neutral" as const, + onRemove: () => setSearchQuery(""), + }, + ] + : []), + ] + + const addFilterOptions = uniqueServices + .filter((s) => !selectedServices.has(s)) + .map((s) => ({ value: s, label: s })) + + const clearAll = () => { + resetFilters() + setSearchQuery("") + setStatusFilter("all") + setExcludeSpamIPs(false) + } + if (loading && issues.length === 0) { return ( -
- - Loading issues... +
+
) } return ( -
-
-
- - - Deduplicated Issues - +
+ setKindFilter(v as KindFilter), + }} + chips={chips} + addFilterOptions={addFilterOptions} + onAddFilter={(v) => toggleService(v)} + matchText={`Matching ${filteredIssues.length} of ${issues.length} issues · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`} + onClearAll={activeFilterCount > 0 ? clearAll : undefined} + /> - {/* Status filter chips */} -
- {statusFilters.map(({ value, label }) => ( - - ))} -
- - - - {/* Kind filter chips */} -
- {kindFilters.map(({ value, label, Icon }) => ( - - ))} -
- - - {filteredIssues.length} shown - -
- -
- {error && Failed to load: {error}} - - - - -
-
- - {detectorTest && ( -
-
-

- {detectorTest.passed ? "Spike detector verified" : "Spike detector test failed"} -

-

- {detectorTest.error ?? detectorTest.scenario} -

-
- {detectorTest.row && ( -
- baseline {(detectorTest.row.baselineErrorRate * 100).toFixed(1)}% - future errors {detectorTest.row.futureErrorCount} - future {(detectorTest.row.futureErrorRate * 100).toFixed(1)}% - {detectorTest.row.labelKnown && detectorTest.row.label ? "label: spike" : "label: not spike"} -
- )} -
- )} - - {showSpamIPs && ( -
-
-
-
Promoted honeypot IPs
-
Used by the ingress tarpit synchronizer.
+
+
+
+

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

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

Failed to load: {error}

)} - {spamIPs.length > 0 && ( -
- {spamIPs.map((entry) => ( -
- {entry.ip} - ×{entry.count} + + {detectorTest && ( +
+
+

+ {detectorTest.passed ? "Spike detector verified" : "Spike detector test failed"} +

+

{detectorTest.error ?? detectorTest.scenario}

+
+ {detectorTest.row && ( +
+ baseline {(detectorTest.row.baselineErrorRate * 100).toFixed(1)}% + future errors {detectorTest.row.futureErrorCount} + future {(detectorTest.row.futureErrorRate * 100).toFixed(1)}% + {detectorTest.row.labelKnown && detectorTest.row.label ? "label: spike" : "label: not spike"}
+ )} +
+ )} + + {showSpamIPs && ( +
+
+
+
Promoted honeypot IPs
+
Used by the ingress tarpit synchronizer.
+
+ +
+ {spamIPsLoading &&
Loading…
} + {spamIPsError &&
Failed to load: {spamIPsError}
} + {!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && ( +
No promoted IPs.
+ )} + {spamIPs.length > 0 && ( +
+ {spamIPs.map((entry) => ( +
+ {entry.ip} + ×{entry.count} +
+ ))} +
+ )} +
+ )} + + {!showSpamIPs && ( + + )} + + {primaryIssues.length === 0 ? ( +
+
+ ) : ( +
+ {primaryIssues.map((issue) => ( + ))}
)} -
- )} - {filteredIssues.length === 0 ? ( -
-
- -
-

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

-

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

+ {secondaryIssues.length > 0 && ( + <> + + {showSecondary && ( +
+ {secondaryIssues.map((issue) => ( + + ))} +
+ )} + + )}
- ) : ( -
- {filteredIssues.map((issue) => ( - - ))} -
- )} + + +
setNeedleModalOpen(false)} />
diff --git a/otel-ui/src/components/MetricsVisualizer.tsx b/otel-ui/src/components/MetricsVisualizer.tsx index c0a4a9d..5d91848 100644 --- a/otel-ui/src/components/MetricsVisualizer.tsx +++ b/otel-ui/src/components/MetricsVisualizer.tsx @@ -291,10 +291,10 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met }, [selectedPod, isMultiPod, podLatestMap, latestPoint, aggMode, activePods]); const gradients = [ - { from: "#818CF8", to: "#38BDF8" }, - { from: "#34D399", to: "#38BDF8" }, - { from: "#F472B6", to: "#FB7185" }, - { from: "#FBBF24", to: "#F59E0B" }, + { from: "var(--ds-accent)", to: "var(--ds-accent)" }, + { from: "var(--ds-ok)", to: "var(--ds-accent)" }, + { from: "var(--ds-accent)", to: "var(--ds-critical)" }, + { from: "var(--ds-warn)", to: "var(--ds-warn)" }, ]; const color = gradients[index % gradients.length]; const gradientId = `gradient-${index}-${name.replace(/[^a-zA-Z0-9]/g, "_")}`; @@ -303,9 +303,9 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met if (active && payload && payload.length) { const data = payload[0].payload; return ( -
-
- +
+
+ {new Date(data.time).toLocaleTimeString([], { hour12: false, hour: "2-digit", @@ -315,7 +315,7 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met })}
-
+
{selectedPod !== "all" ? "Solo Value" : isMultiPod @@ -328,7 +328,7 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met : "Value" : "Value"}
- + {valueFormatter.format(data.value ?? data.avg ?? data.total ?? 0)} {unit || ""}
@@ -336,32 +336,32 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met {/* If fleet average, show min-to-max envelope in tooltip */} {isMultiPod && selectedPod === "all" && data.min !== undefined && data.max !== undefined && ( -
+
Spread: - min {valueFormatter.format(data.min)} • max{" "} - {valueFormatter.format(data.max)} + min {valueFormatter.format(data.min)} • max{" "} + {valueFormatter.format(data.max)}
)} {/* Pod breakdown in tooltip */} {isMultiPod && selectedPod === "all" && ( -
-
+
+
Pod Breakdown ({activePods.length}): - click pod to solo + click pod to solo
{activePods.map((pod) => { const pal = getPodPalette(pod); const val = data[pod] ?? 0; return (
- + {pod} - + {valueFormatter.format(val)} {unit || ""}
@@ -371,16 +371,16 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met )}
- Service: + Service: {service}
{data.attributes && data.attributes.length > 0 && ( -
+
{data.attributes.map((attr: OtelAttribute) => (
- {attr.key}: - + {attr.key}: + {formatAttributeValue(attr.value)}
@@ -394,14 +394,14 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met }; return ( -
-
+
+
{type === "gauge" ? ( @@ -426,19 +426,19 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met {selectedPod !== "all" ? ( ) : isMultiPod ? ( - + {activePods.length} pods ) : null} @@ -446,18 +446,18 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met {type}
-

+

{name}

-

+

{stats?.count} samples {unit ? `• ${unit}` : ""}

@@ -466,13 +466,13 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met
{/* Multi-pod aggregation selector if in chart mode and not soloed */} {viewMode === "chart" && isMultiPod && selectedPod === "all" && ( -
+
-
+
{currentDisplay.label}
-
+
{valueFormatter.format(currentDisplay.value)} - {unit && {unit}} + {unit && {unit}}
{currentDisplay.subtext && ( -
+
{currentDisplay.subtext}
)} @@ -598,8 +598,8 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met - - + + {activePods.map((pod) => { const pal = getPodPalette(pod); @@ -612,7 +612,7 @@ const MetricCard = ({ name, service, type, points, unit, pods = [], index }: Met ); })} - + -
- +
+
+ Pods ({activePods.length}):
{selectedPod !== "all" && ( ); })}
)} -