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" && ( ); })}
)} -