more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect, useRef } from "react";
import { useObservabilityData } from "./hooks/useObservabilityData";
import { Sidebar } from "./components/Sidebar";
import { TopHeader } from "./components/TopHeader";
import { CommandAndQueryBar } from "./components/CommandAndQueryBar";
import { WaveformHistogram } from "./components/WaveformHistogram";
import { EntryVisualization } from "./components/EntryVisualization";
import { handleCallback, login } from "./lib/auth";
function AuthCallbackView() {
const [error, setError] = useState<string | null>(null);
const handledRef = useRef(false);
useEffect(() => {
if (handledRef.current) return;
handledRef.current = true;
handleCallback()
.then(() => {
window.location.href = "/";
})
.catch((err) => {
console.error("Authentication callback error:", err);
setError(err instanceof Error ? err.message : String(err));
});
}, []);
return (
<div className="min-h-screen bg-background text-on-surface flex items-center justify-center p-6">
<div className="max-w-md w-full rounded-2xl border border-surface-container bg-surface-container-low p-8 text-center shadow-xl">
{error ? (
<div className="flex flex-col items-center gap-4">
<div className="w-14 h-14 rounded-full bg-error-container text-on-error-container flex items-center justify-center">
<span className="material-symbols-outlined text-[32px]">error</span>
</div>
<div>
<h2 className="text-lg font-bold text-error">Authentication Failed</h2>
<p className="text-xs text-outline mt-1">{error}</p>
</div>
<button
type="button"
onClick={() => login()}
className="mt-2 px-4 py-2 rounded bg-primary text-on-primary font-mono text-xs hover:bg-primary/90 transition-colors"
>
Try again
</button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 text-primary flex items-center justify-center animate-pulse">
<span className="material-symbols-outlined text-[32px]">lock</span>
</div>
<div>
<h2 className="text-lg font-semibold text-on-surface">Completing sign in</h2>
<p className="text-xs text-outline mt-1">Exchanging authorization code with OIDC provider…</p>
</div>
</div>
)}
</div>
</div>
);
}
function App() {
const { isConnected, traces, logs, metrics } = useObservabilityData();
const hasLoadedAnyData = traces.length > 0 || logs.length > 0 || metrics.length > 0;
const isCallback =
typeof window !== "undefined" &&
(window.location.pathname === "/auth_callback" ||
window.location.search.includes("code="));
if (isCallback) {
return <AuthCallbackView />;
}
return (
<div className="bg-background font-body text-on-surface min-h-screen antialiased selection:bg-primary-container selection:text-on-primary-container">
{/* Fixed Left Navigation Sidebar (w-72) */}
<Sidebar />
{/* Main Content Area offset by pl-72 */}
<div className="pl-72">
{/* Fixed Top Header Bar */}
<TopHeader />
{/* Main Telemetry Console View */}
<main className="relative pt-18 w-full px-6 bg-background min-h-screen pb-12">
<div className="flex flex-col w-full">
{/* Real Anomaly Radar & Filter Bar */}
<CommandAndQueryBar />
{/* Ingest & Incident Waveform Histogram */}
<WaveformHistogram />
{/* Telemetry Visualizations & Views */}
<section className="relative flex-1 flex flex-col min-h-0">
{isConnected || hasLoadedAnyData ? (
<EntryVisualization />
) : (
<div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-4 text-center rounded-2xl border border-dashed border-surface-container-high p-12 bg-surface-container-lowest">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-container-high text-primary-container shadow-lg animate-pulse">
<span className="material-symbols-outlined text-[32px]">
cloud_sync
</span>
</div>
<div>
<p className="text-base font-semibold text-on-surface">
Establishing telemetry pipeline
</p>
<p className="text-xs text-outline mt-1 max-w-sm mx-auto">
Connecting to OTLP collector. Listening for incoming traces, logs, and metrics.
</p>
</div>
</div>
)}
</section>
</div>
</main>
</div>
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,251 @@
import React from "react";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { useIssues } from "../hooks/useIssues";
import { getServiceName, isSpanError } from "../lib/otel-utils";
export const CommandAndQueryBar: React.FC = () => {
const {
logs,
traces,
metrics,
searchQuery,
setSearchQuery,
showErrorsOnly,
setShowErrorsOnly,
selectedServices,
toggleService,
resetFilters,
minDurationMs,
setMinDurationMs,
timeWindow,
setTimeWindow,
setActiveTab,
} = useObservabilityData();
const { issues } = useIssues();
// Compute real error metrics from filtered signals
const errorStats = React.useMemo(() => {
let errorCount = 0;
const servicesWithErrors = new Set<string>();
logs.forEach((l) =>
(l.scopeLogs ?? []).forEach((sl) =>
(sl.logRecords ?? []).forEach((r) => {
if ((r.severityNumber ?? 0) >= 17) {
errorCount++;
const sName = getServiceName(l.resource?.attributes ?? [], r.attributes ?? []);
if (sName !== "unknown-service") {
servicesWithErrors.add(sName);
}
}
}),
),
);
traces.forEach((t) =>
(t.resourceSpans ?? []).forEach((rs) =>
(rs.scopeSpans ?? []).forEach((ss) =>
(ss.spans ?? []).forEach((s) => {
if (isSpanError(s)) {
errorCount++;
const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []);
if (sName !== "unknown-service") {
servicesWithErrors.add(sName);
}
}
}),
),
),
);
return {
errorCount,
services: Array.from(servicesWithErrors),
};
}, [logs, traces]);
// Top open issue if available
const topOpenIssue = React.useMemo(() => {
const open = issues.filter((i) => !i.status || i.status === "open");
return open.sort((a, b) => b.count - a.count)[0] || null;
}, [issues]);
const totalFilteredEvents = logs.length + traces.length + metrics.length;
const exportNDJSON = () => {
const records = [
...traces.map((t) => JSON.stringify({ type: "traces", ...t })),
...logs.map((l) => JSON.stringify({ type: "logs", ...l })),
...metrics.map((m) => JSON.stringify({ type: "metrics", ...m })),
].join("\n");
const blob = new Blob([records], { type: "application/x-ndjson" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `hyperscope-filtered-telemetry-${new Date().toISOString()}.ndjson`;
a.click();
URL.revokeObjectURL(url);
};
const hasActiveFilters =
Boolean(searchQuery) ||
showErrorsOnly ||
selectedServices.size > 0 ||
minDurationMs > 0 ||
timeWindow !== "all";
return (
<div className="flex flex-col gap-2.5 mb-4 font-body">
{/* Real Anomaly Radar Banner */}
{(errorStats.errorCount > 0 || topOpenIssue) && (
<div className="rounded-xl border border-rose-500/30 bg-gradient-to-r from-rose-950/40 via-slate-950/60 to-rose-950/20 p-3 shadow-lg flex flex-col sm:flex-row sm:items-center justify-between gap-3 animate-in fade-in duration-300">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-rose-500/20 text-rose-400 border border-rose-500/40">
<span className="material-symbols-outlined text-[18px]">warning</span>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-rose-300 uppercase tracking-wider">
Anomaly Radar:
</span>
<span className="font-mono text-xs text-white truncate">
{errorStats.errorCount > 0
? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})`
: `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`}
</span>
</div>
{topOpenIssue && (
<span className="text-[11px] text-slate-400 truncate">
Top Issue: <span className="text-slate-200 font-semibold">{topOpenIssue.title}</span> (Service: {topOpenIssue.service}, ×{topOpenIssue.count})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("tab0")}
className="px-3 py-1 rounded-lg bg-rose-500 hover:bg-rose-600 text-white font-mono text-xs font-bold transition-colors shadow-sm"
>
Triage Issues
</button>
{!showErrorsOnly && (
<button
type="button"
onClick={() => setShowErrorsOnly(true)}
className="px-2.5 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 font-mono text-xs transition-colors"
>
Isolate Errors
</button>
)}
</div>
</div>
)}
{/* Filter and Telemetry Status Bar */}
<div className="flex flex-wrap items-center justify-between gap-2.5 p-3 rounded-xl bg-surface-container-lowest border border-surface-container/50 shadow-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-[10px] text-outline uppercase tracking-wider font-semibold shrink-0">
Active Context:
</span>
<span className="font-mono text-xs text-secondary font-semibold">
{totalFilteredEvents} Signals ({timeWindow === "all" ? "All Time" : `Last ${timeWindow}`})
</span>
{/* Selected Service Pills */}
{Array.from(selectedServices).map((service) => (
<span
key={service}
className="inline-flex items-center gap-1 rounded-full bg-primary-container/20 text-primary-container border border-primary-container/40 px-2.5 py-0.5 font-mono text-xs font-semibold"
>
<span>service:{service}</span>
<button
type="button"
onClick={() => toggleService(service)}
className="hover:text-white"
>
×
</button>
</span>
))}
{/* Errors Only Pill */}
{showErrorsOnly && (
<span className="inline-flex items-center gap-1 rounded-full bg-error-container text-on-error-container px-2.5 py-0.5 font-mono text-xs font-semibold">
<span>Errors Only</span>
<button
type="button"
onClick={() => setShowErrorsOnly(false)}
className="hover:text-white"
>
×
</button>
</span>
)}
{/* Slow Spans Pill */}
{minDurationMs > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40 px-2.5 py-0.5 font-mono text-xs font-semibold">
<span>Latency &gt;{minDurationMs}ms</span>
<button
type="button"
onClick={() => setMinDurationMs(0)}
className="hover:text-white"
>
×
</button>
</span>
)}
{/* Search Query Pill */}
{searchQuery && (
<span className="inline-flex items-center gap-1 rounded-full bg-surface-container text-on-surface border border-surface-container-high px-2.5 py-0.5 font-mono text-xs">
<span>query: &quot;{searchQuery}&quot;</span>
<button
type="button"
onClick={() => setSearchQuery("")}
className="hover:text-white"
>
×
</button>
</span>
)}
{hasActiveFilters && (
<button
type="button"
onClick={() => {
resetFilters();
setMinDurationMs(0);
setTimeWindow("all");
}}
className="text-outline hover:text-white font-mono text-xs underline ml-1"
>
Clear all
</button>
)}
</div>
{/* Export and Action buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={exportNDJSON}
className="flex items-center gap-1 px-2.5 py-1 bg-surface-container-low hover:bg-surface-container text-on-surface font-mono text-xs rounded-lg transition-colors border border-surface-container/40 shadow-sm"
title="Export currently filtered telemetry signals as NDJSON"
>
<span className="material-symbols-outlined text-[15px]">download</span>
<span>Export NDJSON</span>
</button>
</div>
</div>
</div>
);
};
export default CommandAndQueryBar;
@@ -0,0 +1,9 @@
import { TabsComponent } from "./Tabs";
export const EntryVisualization = () => {
return (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
<TabsComponent />
</div>
);
};
File diff suppressed because it is too large. Load diff
+372
View File
@@ -0,0 +1,372 @@
import { useMemo } from "react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { getServicePalette } from "../lib/otel-utils";
export interface GaugeMeterProps {
name: string;
service?: string;
value: number;
min?: number;
max?: number;
avg?: number;
unit?: string;
size?: "sm" | "md" | "lg";
thresholds?: {
warn: number;
danger: number;
};
subtitle?: string;
history?: number[];
showTrend?: boolean;
isSum?: boolean;
aggLabel?: string;
soloPod?: string;
podCount?: number;
podSummaries?: Array<{ pod: string; value: number }>;
}
const formatNumber = (val: number, maxDecimals = 2) => {
if (Math.abs(val) >= 1_000_000) {
return `${(val / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(val) >= 1_000) {
return `${(val / 1_000).toFixed(1)}k`;
}
if (Number.isInteger(val)) {
return val.toString();
}
return val.toFixed(maxDecimals);
};
// SVG arc calculations
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const describeArc = (x: number, y: number, radius: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return ["M", start.x, start.y, "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y].join(" ");
};
export const GaugeMeter = ({
name,
service,
value,
min: customMin,
max: customMax,
avg,
unit = "",
size = "md",
thresholds,
subtitle,
history = [],
showTrend = true,
isSum = false,
aggLabel,
soloPod,
podCount,
podSummaries,
}: GaugeMeterProps) => {
const servicePalette = service ? getServicePalette(service) : null;
// Determine min/max range
const { min, max } = useMemo(() => {
let computedMin = customMin !== undefined ? customMin : 0;
let computedMax = customMax !== undefined ? customMax : 100;
if (history.length > 0 && customMin === undefined && customMax === undefined) {
const histMin = Math.min(...history);
const histMax = Math.max(...history);
if (histMax <= 1 && histMin >= 0) {
// Percentage ratio 0..1
computedMin = 0;
computedMax = 1;
} else if (histMax <= 100 && histMin >= 0) {
// 0..100%
computedMin = 0;
computedMax = 100;
} else {
computedMin = Math.floor(Math.min(0, histMin));
computedMax = Math.ceil(histMax * 1.15) || 100;
}
}
if (computedMax <= computedMin) {
computedMax = computedMin + 1;
}
return { min: computedMin, max: computedMax };
}, [customMin, customMax, history]);
// Normalized percent 0..100
const percent = useMemo(() => {
const clamped = Math.max(min, Math.min(value, max));
return ((clamped - min) / (max - min)) * 100;
}, [value, min, max]);
// Color determination based on thresholds
const warnThreshold = thresholds?.warn ?? 70;
const dangerThreshold = thresholds?.danger ?? 90;
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)",
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)",
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)",
label: "Nominal",
};
}, [percent, warnThreshold, dangerThreshold]);
// Trend calculation
const trend = useMemo(() => {
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" };
}, [value, history, showTrend]);
// Dimensions
const dim = size === "sm" ? 120 : size === "lg" ? 220 : 160;
const strokeWidth = size === "sm" ? 8 : size === "lg" ? 14 : 10;
const radius = (dim - strokeWidth * 2) / 2;
const centerX = dim / 2;
const centerY = dim / 2 + (size === "sm" ? 4 : 8);
// 240 degree arc from -120 to +120 degrees
const startAngle = -120;
const endAngle = 120;
const totalAngle = endAngle - startAngle;
const valueAngle = startAngle + (percent / 100) * totalAngle;
const bgArc = describeArc(centerX, centerY, radius, startAngle, endAngle);
const valueArc = describeArc(centerX, centerY, radius, startAngle, Math.min(valueAngle, endAngle));
// Ticks at 0, 50, 100
const tickAngles = [startAngle, startAngle + totalAngle * 0.5, endAngle];
const gaugeId = useMemo(
() => `${service ? service.replace(/[^a-zA-Z0-9]/g, "_") + "_" : ""}${name.replace(/[^a-zA-Z0-9]/g, "_")}`,
[service, name]
);
return (
<div className="flex flex-col items-center justify-between rounded-2xl border border-white/10 bg-slate-900/40 p-4 shadow-lg backdrop-blur-sm transition-all hover:border-white/20">
<div className="flex w-full items-start justify-between gap-2 border-b border-white/5 pb-2">
<div className="flex flex-col min-w-0 pr-1">
<div className="flex items-center gap-1 flex-wrap mb-1">
{service && (
<span
className={`inline-flex items-center gap-1 self-start rounded px-1.5 py-0.5 text-[9px] font-mono font-bold uppercase tracking-wider border ${servicePalette?.bg} ${servicePalette?.text} ${servicePalette?.border}`}
title={`Owning Service: ${service}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-80" />
<span className="truncate max-w-[120px]">{service}</span>
</span>
)}
{podCount !== undefined && podCount > 1 && (
<span
className="inline-flex items-center gap-1 rounded bg-sky-500/15 px-1.5 py-0.5 text-[9px] font-mono font-bold text-sky-300 border border-sky-500/30"
title={`Sum of values across ${podCount} pods`}
>
<span className="text-[11px] font-black leading-none text-sky-400">∑</span>
<span>{podCount} pods</span>
</span>
)}
</div>
<span className="truncate text-xs font-bold uppercase tracking-wider text-slate-300" title={name}>
{name}
</span>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-wider ${statusColor.bg} ${statusColor.text} ${statusColor.border} border`}>
{statusColor.label}
</span>
</div>
<div className="relative my-2 flex items-center justify-center">
<svg width={dim} height={dim * 0.85} viewBox={`0 0 ${dim} ${dim * 0.85}`} className="overflow-visible">
<defs>
<linearGradient id={`gauge-grad-${gaugeId}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={statusColor.gradientStart} />
<stop offset="100%" stopColor={statusColor.gradientEnd} />
</linearGradient>
<filter id={`gauge-glow-${gaugeId}`} x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor={statusColor.glow} />
</filter>
</defs>
{/* Background Track */}
<path
d={bgArc}
fill="none"
stroke="rgba(255, 255, 255, 0.08)"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Ticks */}
{tickAngles.map((angle, idx) => {
const inner = polarToCartesian(centerX, centerY, radius - strokeWidth * 0.8, angle);
const outer = polarToCartesian(centerX, centerY, radius + strokeWidth * 0.8, angle);
return (
<line
key={idx}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
stroke="rgba(255, 255, 255, 0.2)"
strokeWidth={1.5}
/>
);
})}
{/* Active Value Arc */}
{percent > 0 && (
<path
d={valueArc}
fill="none"
stroke={`url(#gauge-grad-${gaugeId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
filter={`url(#gauge-glow-${gaugeId})`}
/>
)}
{/* Needle / Value dot indicator */}
{percent > 0 && (() => {
const tip = polarToCartesian(centerX, centerY, radius, valueAngle);
return (
<circle
cx={tip.x}
cy={tip.y}
r={strokeWidth * 0.6}
fill="#ffffff"
stroke={statusColor.stroke}
strokeWidth={2}
className="transition-all duration-300"
/>
);
})()}
</svg>
{/* Center Readout */}
<div className="absolute inset-0 flex flex-col items-center justify-center pt-3 text-center pointer-events-none">
{soloPod ? (
<span className="text-[8px] font-mono uppercase font-black text-amber-400 tracking-wider truncate max-w-[100px]" title={`Solo: ${soloPod}`}>
{soloPod}
</span>
) : aggLabel ? (
<span className="text-[8.5px] font-mono uppercase font-black text-sky-400 tracking-wider">
{aggLabel}
</span>
) : podCount !== undefined && podCount > 1 ? (
<span className="text-[8.5px] font-mono uppercase font-black text-sky-400 tracking-wider">
{isSum ? "Sum (∑)" : "Avg (μ)"}
</span>
) : null}
<div className="flex items-baseline gap-1">
<span className={`font-mono font-black tracking-tight ${size === "sm" ? "text-lg" : size === "lg" ? "text-3xl" : "text-2xl"} ${statusColor.text}`}>
{formatNumber(value)}
</span>
{unit && (
<span className="text-[10px] font-bold uppercase text-slate-400">
{unit}
</span>
)}
</div>
{podCount !== undefined && podCount > 1 ? (
<span
className="text-[9px] font-mono text-slate-400 truncate max-w-[100px]"
title={isSum ? `Average: ${formatNumber(value / podCount)} per pod` : `Fleet average across ${podCount} pods`}
>
{isSum ? `~${formatNumber(value / podCount)}/pod` : `${podCount} pods avg`}
</span>
) : subtitle ? (
<span className="text-[10px] text-slate-500 truncate max-w-[90px]">
{subtitle}
</span>
) : null}
</div>
</div>
{/* Footer range indicators & trend */}
<div className="flex w-full items-center justify-between border-t border-white/5 pt-2 text-[10px] text-slate-400">
<span className="font-mono">
<span className="text-slate-600">min</span> {formatNumber(min)}
</span>
{trend && (
<span className={`inline-flex items-center gap-0.5 font-mono text-[9px] font-bold ${trend.color}`}>
{trend.direction === "up" && <ArrowUpRight className="h-2.5 w-2.5" />}
{trend.direction === "down" && <ArrowDownRight className="h-2.5 w-2.5" />}
{trend.direction === "flat" && <Minus className="h-2.5 w-2.5" />}
{trend.text}
</span>
)}
{avg !== undefined && (
<span className="font-mono text-slate-400">
<span className="text-slate-600">avg</span> {formatNumber(avg)}
</span>
)}
<span className="font-mono">
<span className="text-slate-600">max</span> {formatNumber(max)}
</span>
</div>
{/* Pod Breakdown pills if available */}
{podSummaries && podSummaries.length > 1 && (
<div className="mt-2 flex w-full flex-wrap items-center gap-1 border-t border-white/5 pt-1.5">
{podSummaries.slice(0, 3).map((ps) => (
<div
key={ps.pod}
className="flex items-center gap-1 rounded bg-white/5 px-1.5 py-0.5 text-[8.5px] font-mono text-slate-400"
title={`${ps.pod}: ${formatNumber(ps.value)} ${unit || ""}`}
>
<span className="truncate max-w-[65px] text-slate-300">{ps.pod}</span>
<span className="font-bold text-sky-300">{formatNumber(ps.value)}</span>
</div>
))}
{podSummaries.length > 3 && (
<span className="text-[8px] font-mono text-slate-500 self-center">
+{podSummaries.length - 3}
</span>
)}
</div>
)}
</div>
);
};
+601
View File
@@ -0,0 +1,601 @@
import { useMemo, useState } from "react"
import {
Activity,
AlertOctagon,
Bug,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
EyeOff,
ExternalLink,
Globe,
MonitorSmartphone,
RefreshCw,
RotateCcw,
ScrollText,
ShieldBan,
X,
} from "lucide-react"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { authFetch } from "../lib/auth"
import { formatRelativeTime } from "../lib/timeUtils"
import { getServicePalette } from "../lib/otel-utils"
import {
issueStatusMeta,
issueStatusOf,
latestBreadcrumbs,
latestStacktrace,
sampleContext,
trendPoints,
} from "../lib/issue-utils"
import type { Issue, IssueSample } from "../lib/types"
const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
span_error: { label: "Trace Error", Icon: Activity },
log_error: { label: "Log Error", Icon: ScrollText },
frontend_error: { label: "Frontend Error", Icon: Globe },
}
type StatusFilter = "all" | "open" | "resolved" | "ignored"
type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
interface HoneypotIP {
ip: string
count: number
first_seen: string
last_seen: string
promoted_until: string
}
const statusFilters: { 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 },
]
// 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 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}`
return (
<span
className="inline-flex shrink-0 flex-col items-end gap-0.5"
title="Occurrences per hour (last 24h)"
>
<svg width={width} height={height} className="overflow-visible">
<path d={`${path} L${width},${height} L0,${height} Z`} fill={`url(#${gradientId})`} opacity="0.35" />
<path d={path} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" className="text-rose-400/80" />
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" className="text-rose-400" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" className="text-rose-400" />
</linearGradient>
</defs>
</svg>
</span>
)
}
const StatusBadge = ({ issue }: { issue: Issue }) => {
const status = issueStatusOf(issue)
const meta = issueStatusMeta[status]
return (
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${meta.className}`}>
{meta.label}
</span>
)
}
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">
{stacktrace}
</pre>
)
const BreadcrumbsView = ({ issue }: { issue: Issue }) => {
const crumbs = latestBreadcrumbs(issue)
if (!crumbs.length) return null
return (
<div className="space-y-1.5">
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
<Clock className="h-3 w-3" />
Breadcrumbs before error
</p>
<div className="max-h-48 space-y-1 overflow-auto rounded-xl border border-white/5 bg-black/40 p-3">
{crumbs.map((crumb, idx) => (
<div key={`${crumb.t}-${idx}`} className="flex items-baseline gap-2 font-mono text-[10px]">
<span className="shrink-0 text-slate-600">
{new Date(crumb.t).toLocaleTimeString([], { hour12: false })}
</span>
<span className="shrink-0 rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-sky-400/80">
{crumb.category}
</span>
<span className="min-w-0 break-all text-slate-300">{crumb.message.slice(0, 200)}</span>
</div>
))}
</div>
</div>
)
}
const SampleContextChips = ({ sample }: { sample: IssueSample }) => {
const ctx = sampleContext(sample)
if (!ctx.length) return null
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 font-mono text-[10px] font-bold text-amber-300">
{ctx.join(" · ")}
</span>
)
}
const StatusActions = ({
issue,
onStatusChange,
busy,
}: {
issue: Issue
onStatusChange: (issue: Issue, status: string) => void
busy: boolean
}) => {
const status = issueStatusOf(issue)
const actions: { to: string; label: string; Icon: typeof CheckCircle2 }[] =
status === "open"
? [
{ to: "resolved", label: "Resolve", Icon: CheckCircle2 },
{ to: "ignored", label: "Ignore", Icon: EyeOff },
]
: [{ to: "open", label: "Reopen", Icon: RotateCcw }]
return (
<div className="flex items-center gap-2">
{actions.map(({ to, label, Icon }) => (
<button
key={to}
disabled={busy}
onClick={(e) => {
e.stopPropagation()
onStatusChange(issue, to)
}}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-all hover:border-white/20 hover:text-white disabled:opacity-50"
>
<Icon className="h-3 w-3" />
{label}
</button>
))}
</div>
)
}
const IssueCard = ({
issue,
onStatusChange,
statusBusy,
}: {
issue: Issue
onStatusChange: (issue: Issue, status: string) => void
statusBusy: boolean
}) => {
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 viewTrace = (traceId: string, spanId?: string) => {
setActiveTraceId(traceId)
if (spanId) setActiveSpanId(spanId)
setActiveTab("tab1")
}
return (
<div className="group relative flex flex-col overflow-hidden rounded-xl border border-rose-500/10 bg-slate-950/40 transition-all hover:border-rose-500/25 hover:bg-slate-900/50">
<div className={`absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b ${badgeGradient} opacity-50 transition-opacity group-hover:opacity-100`} />
<div
onClick={() => setExpanded((prev) => !prev)}
className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4"
>
<span className={`inline-flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r ${badgeGradient} text-slate-950`}>
<meta.Icon className="h-3 w-3" />
{meta.label}
</span>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${palette.bg} ${palette.text} ${palette.border} border`}>
{issue.service}
</span>
<span className="flex-1 min-w-[240px] break-all font-mono text-[12px] text-slate-200">
{issue.title}
</span>
<TrendSparkline issue={issue} />
<span className="shrink-0 rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold text-rose-300">
×{issue.count}
</span>
<StatusBadge issue={issue} />
<span className="shrink-0 text-[10px] text-slate-500 whitespace-nowrap">
last seen {formatRelativeTime(issue.lastSeen / 1e6)}
</span>
{expanded ? <ChevronUp className="h-3 w-3 text-slate-500" /> : <ChevronDown className="h-3 w-3 text-slate-500" />}
</div>
{expanded && (
<div className="space-y-4 border-t border-white/5 bg-black/40 p-4 text-[12px]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4 text-[10px] text-slate-500">
<span>First seen {formatRelativeTime(issue.firstSeen / 1e6)}</span>
{issue.affectedServices.length > 1 && (
<span>Affects: {issue.affectedServices.join(", ")}</span>
)}
{issue.status === "resolved" && issue.resolvedAt ? (
<span>Resolved {formatRelativeTime(issue.resolvedAt / 1e6)}</span>
) : null}
</div>
<StatusActions issue={issue} onStatusChange={onStatusChange} busy={statusBusy} />
</div>
{stacktrace && (
<div className="space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
{hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}
</p>
<StacktraceView stacktrace={stacktrace} />
</div>
)}
<BreadcrumbsView issue={issue} />
<div className="flex flex-wrap gap-4 text-[10px] text-slate-500">
{(contextSample?.userAgent || latestSample?.userAgent) && (
<span className="inline-flex items-center gap-1.5 max-w-[360px] truncate" title={contextSample?.userAgent || latestSample?.userAgent}>
<MonitorSmartphone className="h-3 w-3 shrink-0" />
{(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)}
</span>
)}
{(contextSample?.release || latestSample?.release) && (
<span>release: {contextSample?.release || latestSample?.release}</span>
)}
{(contextSample?.environment || latestSample?.environment) && (
<span>env: {contextSample?.environment || latestSample?.environment}</span>
)}
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Recent occurrences ({issue.samples.length})
</p>
<div className="space-y-1.5">
{[...issue.samples].reverse().map((sample, idx) => (
<div
key={`${sample.timestamp}-${idx}`}
className="flex flex-wrap items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-2.5 py-1.5"
>
<span className="shrink-0 text-slate-500 font-mono text-[10px]">
{new Date(sample.timestamp / 1e6).toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
<SampleContextChips sample={sample} />
<span className="flex-1 min-w-[160px] break-all text-slate-300">{sample.message}</span>
{sample.url && (
<a
href={sample.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500/10 border border-violet-400/20 px-2.5 py-0.5 text-[10px] font-bold text-violet-300 hover:bg-violet-500/20 hover:border-violet-400/30 transition-all max-w-[220px] truncate"
title={sample.url}
>
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{sample.url.replace(/^https?:\/\//, "")}</span>
</a>
)}
{sample.traceId && (
<button
onClick={(e) => {
e.stopPropagation()
viewTrace(sample.traceId!, sample.spanId)
}}
className="group/trace flex shrink-0 items-center gap-1.5 rounded-full bg-sky-500/10 border border-sky-400/20 px-2.5 py-0.5 text-[10px] font-bold text-sky-400 hover:bg-sky-500/20 hover:border-sky-400/30 transition-all"
>
<Activity className="h-2.5 w-2.5 group-hover/trace:animate-pulse" />
TRACE:{sample.traceId.slice(0, 6)}
</button>
)}
</div>
))}
</div>
</div>
{latestSample?.traceId && (
<div className="mt-1 pt-4 border-t border-white/5 flex justify-end">
<button
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
className="inline-flex items-center gap-2 rounded-xl bg-sky-500 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-950 transition-all hover:bg-sky-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-sky-500/20"
>
<Activity className="h-3 w-3" />
View Latest Trace
</button>
</div>
)}
</div>
)}
</div>
)
}
interface IssuesVisualizerProps {
issues: Issue[]
loading: boolean
error: string | null
refresh: () => void
onStatusChange: (issue: Issue, status: string) => void
statusBusy: boolean
}
export const IssuesVisualizer = ({
issues,
loading,
error,
refresh,
onStatusChange,
statusBusy,
}: IssuesVisualizerProps) => {
const { selectedServices, searchQuery } = useObservabilityData()
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
const [showSpamIPs, setShowSpamIPs] = useState(false)
const [spamIPs, setSpamIPs] = useState<HoneypotIP[]>([])
const [spamIPsLoading, setSpamIPsLoading] = useState(false)
const [spamIPsError, setSpamIPsError] = useState<string | null>(null)
const loadSpamIPs = async () => {
setSpamIPsLoading(true)
setSpamIPsError(null)
try {
const response = await authFetch("/api/honeypot/ips")
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setSpamIPs((await response.json()) as HoneypotIP[])
} catch (err) {
setSpamIPsError(err instanceof Error ? err.message : String(err))
} finally {
setSpamIPsLoading(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 }
issues.forEach((issue) => {
counts[issueStatusOf(issue)] += 1
})
return counts
}, [issues])
const filteredIssues = useMemo(() => {
return issues.filter((issue) => {
if (statusFilter !== "all" && issueStatusOf(issue) !== statusFilter) return false
if (kindFilter !== "all" && issue.kind !== kindFilter) return false
if (
excludeSpamIPs &&
issue.samples.some(
(sample) =>
(sample.httpStatus ?? 0) >= 400 &&
Boolean(sample.sourceIp) &&
spamIPSet.has(sample.sourceIp!),
)
) {
return false
}
if (selectedServices.size > 0) {
const matches =
selectedServices.has(issue.service) ||
issue.affectedServices.some((s) => selectedServices.has(s))
if (!matches) return false
}
if (searchQuery) {
const q = searchQuery.toLowerCase()
const matches =
issue.title.toLowerCase().includes(q) || issue.service.toLowerCase().includes(q)
if (!matches) return false
}
return true
})
}, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet])
if (loading && issues.length === 0) {
return (
<div className="py-12 text-center text-sm text-slate-400 flex flex-col items-center justify-center">
<AlertOctagon className="h-10 w-10 mx-auto mb-4 opacity-20 animate-pulse" />
Loading issues...
</div>
)
}
return (
<div className="space-y-4">
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-3 py-2 border-b border-white/5 mb-2 bg-slate-950/80 backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
<AlertOctagon className="h-3 w-3 text-rose-400" />
Deduplicated Issues
</span>
{/* Status filter chips */}
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
{statusFilters.map(({ value, label }) => (
<button
key={value}
onClick={() => setStatusFilter(value)}
className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
statusFilter === value
? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
{label}
<span className="ml-1 opacity-60">{statusCounts[value]}</span>
</button>
))}
</div>
<button
onClick={() => setExcludeSpamIPs((value) => !value)}
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
excludeSpamIPs
? "border-amber-400/40 bg-amber-400/15 text-amber-200"
: "border-white/10 text-slate-500 hover:border-white/20 hover:text-slate-300"
}`}
title="Hide HTTP errors whose recorded source IP is on the honeypot list"
>
<ShieldBan className="h-3 w-3" />
{excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
</button>
{/* Kind filter chips */}
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
{kindFilters.map(({ value, label, Icon }) => (
<button
key={value}
onClick={() => setKindFilter(value)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
kindFilter === value
? "bg-violet-500/20 text-violet-300 ring-1 ring-violet-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
<Icon className="h-3 w-3" />
{label}
</button>
))}
</div>
<span className="text-[10px] text-slate-500">
{filteredIssues.length} shown
</span>
</div>
<div className="flex items-center gap-3">
{error && <span className="text-[10px] text-rose-400">Failed to load: {error}</span>}
<button
onClick={() => {
const next = !showSpamIPs
setShowSpamIPs(next)
if (next && spamIPs.length === 0) void loadSpamIPs()
}}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<ShieldBan className="h-3 w-3" />
Spam IPs ({spamIPs.length})
</button>
<button
onClick={() => refresh()}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
{showSpamIPs && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-3 text-xs text-slate-300">
<div className="flex items-center justify-between gap-3 mb-2">
<div>
<div className="font-bold text-amber-200">Promoted honeypot IPs</div>
<div className="text-[10px] text-slate-500">Used by the ingress tarpit synchronizer.</div>
</div>
<button
onClick={() => setShowSpamIPs(false)}
className="rounded p-1 text-slate-500 hover:text-white"
aria-label="Close spam IP list"
>
<X className="h-4 w-4" />
</button>
</div>
{spamIPsLoading && <div className="text-slate-500">Loading…</div>}
{spamIPsError && <div className="text-rose-300">Failed to load: {spamIPsError}</div>}
{!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
<div className="text-slate-500">No promoted IPs.</div>
)}
{spamIPs.length > 0 && (
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{spamIPs.map((entry) => (
<div key={entry.ip} className="flex items-center justify-between rounded-lg bg-black/20 px-2.5 py-1.5 font-mono">
<span className="text-slate-200">{entry.ip}</span>
<span className="text-[10px] text-amber-300">×{entry.count}</span>
</div>
))}
</div>
)}
</div>
)}
{filteredIssues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
<AlertOctagon className="h-6 w-6 text-emerald-400/60" />
</div>
<p className="text-sm font-medium text-slate-300">
{issues.length === 0 ? "No issues detected" : "No issues match your filters"}
</p>
<p className="text-xs mt-1 text-slate-600">
{issues.length === 0
? "Error-status spans and error/fatal-severity logs will show up here as they arrive."
: "Try adjusting your filters or search query."}
</p>
</div>
) : (
<div className="space-y-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
{filteredIssues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
onStatusChange={onStatusChange}
statusBusy={statusBusy}
/>
))}
</div>
)}
</div>
)
}
export default IssuesVisualizer
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,76 @@
import { useMemo, useState } from "react"
import { GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
import { usePatterns } from "../hooks/usePatterns"
import { formatRelativeTime } from "../lib/timeUtils"
import type { Issue, SimilarIssue } from "../lib/types"
const PatternIssues = ({ pattern, issues, similar, review }: {
pattern: { issueIds: string[] }
issues: Issue[]
similar: (id: string) => Promise<SimilarIssue[]>
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
}) => {
const [expanded, setExpanded] = useState(false)
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
const [busy, setBusy] = useState<string | null>(null)
const members = pattern.issueIds.map((id) => issues.find((issue) => issue.id === id)).filter(Boolean) as Issue[]
const toggle = async (issue: Issue) => {
if (expanded) { setExpanded(false); return }
setExpanded(true)
if (!related[issue.id]) {
const suggestions = await similar(issue.id)
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
}
}
const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
const key = `${issue.id}:${item.issue.id}`
setBusy(key)
try {
await review(issue.id, item.issue.id, decision)
setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((entry) => entry.issue.id === item.issue.id ? { ...entry, decision } : entry) }))
} finally { setBusy(null) }
}
return (
<div className="mt-3 space-y-2 border-t border-white/5 pt-3">
<div className="flex flex-wrap gap-2">
{members.map((issue) => <button key={issue.id} onClick={() => void toggle(issue)} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-left text-[10px] text-slate-300 hover:border-sky-400/40">{issue.title} <span className="text-slate-500">×{issue.count}</span></button>)}
</div>
{expanded && members[0] && <div className="space-y-2 rounded-lg bg-black/20 p-3">
<div className="text-[10px] uppercase tracking-widest text-slate-500">Deterministic suggestions for {members[0].title}</div>
{(related[members[0].id] ?? []).map((item) => {
const key = `${members[0].id}:${item.issue.id}`
return <div key={item.issue.id} className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-slate-300">{item.issue.title}</span><span className="text-sky-300">{Math.round(item.score * 100)}%</span><span className="text-slate-500">{item.reasons.join(" · ")}</span>
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "same")} className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-400/20 px-2 py-1 text-emerald-300 hover:bg-emerald-400/10"><ThumbsUp className="h-3 w-3" /> same</button>
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "different")} className="inline-flex items-center gap-1 rounded border border-rose-400/20 px-2 py-1 text-rose-300 hover:bg-rose-400/10"><ThumbsDown className="h-3 w-3" /> separate</button>
{item.decision && <span className="text-slate-500">reviewed: {item.decision}</span>}
</div>
})}
</div>}
</div>
)
}
export function PatternsVisualizer() {
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
const [query, setQuery] = useState("")
const visible = useMemo(() => patterns.filter((pattern) => `${pattern.service} ${pattern.kind} ${pattern.signature}`.toLowerCase().includes(query.toLowerCase())), [patterns, query])
if (loading && patterns.length === 0) return <div className="py-16 text-center text-sm text-slate-500">Loading deterministic patterns…</div>
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 border-b border-white/5 pb-3">
<div><div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500"><GitBranch className="h-3 w-3 text-violet-400" /> Similarity Patterns</div><div className="mt-1 text-xs text-slate-500">Exact issues stay separate; broader deterministic patterns are reviewable.</div></div>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter patterns…" className="ml-auto rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 outline-none placeholder:text-slate-600 focus:border-sky-400/40" />
<button onClick={() => void refresh()} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white"><RefreshCw className="h-3 w-3" /> Refresh</button>
</div>
{error && <div className="rounded-lg border border-rose-400/20 bg-rose-400/5 p-3 text-xs text-rose-300">Failed to load patterns: {String(error)}</div>}
{visible.length === 0 ? <div className="py-16 text-center text-sm text-slate-500">No deterministic patterns observed yet.</div> : <div className="space-y-3">{visible.map((pattern) => <div key={pattern.id} className="rounded-xl border border-violet-400/15 bg-slate-950/40 p-4">
<div className="flex flex-wrap items-center gap-3"><span className="rounded bg-violet-500/15 px-2 py-1 text-[10px] font-bold uppercase text-violet-300">{pattern.service}</span><span className="text-xs text-slate-400">{pattern.kind}</span><span className="text-xs text-slate-300">{pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrences</span><span className="ml-auto text-[10px] text-slate-600">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span></div>
<div className="mt-3 break-all font-mono text-[10px] text-slate-500">{pattern.signature.split("\u0000").join(" · ")}</div>
<PatternIssues pattern={pattern} issues={issues} similar={similar} review={review} />
</div>)}</div>}
</div>
}
@@ -0,0 +1,48 @@
import { useState } from "react"
import { Activity, CheckCircle2, CloudCog, RefreshCw, XCircle } from "lucide-react"
import { useRollouts } from "../hooks/useRollouts"
import { formatRelativeTime } from "../lib/timeUtils"
import type { RolloutImpact } from "../lib/types"
const phaseStyle: Record<string, { className: string; Icon: typeof Activity }> = {
completed: { className: "text-emerald-300 bg-emerald-400/10 border-emerald-400/20", Icon: CheckCircle2 },
failed: { className: "text-rose-300 bg-rose-400/10 border-rose-400/20", Icon: XCircle },
progressing: { className: "text-amber-300 bg-amber-400/10 border-amber-400/20", Icon: Activity },
}
export function RolloutsVisualizer() {
const { rollouts, loading, error, refresh, impact } = useRollouts()
const [selected, setSelected] = useState<string | null>(null)
const [impactData, setImpactData] = useState<Record<string, RolloutImpact>>({})
const [impactError, setImpactError] = useState<string | null>(null)
const select = async (id: string) => {
setSelected(selected === id ? null : id)
if (impactData[id]) return
try {
setImpactError(null)
const result = await impact(id)
setImpactData((previous) => ({ ...previous, [id]: result }))
}
catch (cause) { setImpactError(cause instanceof Error ? cause.message : String(cause)) }
}
if (loading && rollouts.length === 0) return <div className="py-16 text-center text-sm text-slate-500">Loading cluster rollouts…</div>
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 border-b border-white/5 pb-3">
<div><div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500"><CloudCog className="h-3 w-3 text-sky-400" /> Cluster Rollouts</div><div className="mt-1 text-xs text-slate-500">Read-only Kubernetes deployment state with optional Prometheus impact data.</div></div>
<button onClick={() => void refresh()} className="ml-auto inline-flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white"><RefreshCw className="h-3 w-3" /> Refresh</button>
</div>
{error && <div className="rounded-lg border border-amber-400/20 bg-amber-400/5 p-3 text-xs text-amber-200">Rollout watcher unavailable: {String(error)}</div>}
{impactError && <div className="rounded-lg border border-rose-400/20 bg-rose-400/5 p-3 text-xs text-rose-300">Impact query failed: {impactError}</div>}
{rollouts.length === 0 ? <div className="py-16 text-center text-sm text-slate-500">No rollout data. Enable the Kubernetes watcher and grant its read-only RBAC permissions.</div> : <div className="space-y-2">{rollouts.map((rollout) => {
const status = phaseStyle[rollout.phase] ?? phaseStyle.progressing
const Icon = status.Icon
const data = impactData[rollout.id]
return <div key={rollout.id} className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<button onClick={() => void select(rollout.id)} className="w-full text-left"><div className="flex flex-wrap items-center gap-3"><span className={`inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] font-bold uppercase ${status.className}`}><Icon className="h-3 w-3" /> {rollout.phase}</span><span className="font-mono text-xs text-slate-200">{rollout.namespace}/{rollout.workload}</span><span className="text-xs text-slate-400">revision {rollout.revision || "unknown"}</span><span className="ml-auto text-[10px] text-slate-500">{formatRelativeTime(rollout.lastSeen / 1e6)}</span></div><div className="mt-3 grid gap-2 text-[11px] text-slate-400 sm:grid-cols-2 lg:grid-cols-4"><span>release: <b className="text-slate-200">{rollout.release || "unlabeled"}</b></span><span>image: <b className="font-mono text-slate-200">{rollout.image || "unknown"}</b></span><span>replicas: <b className="text-slate-200">{rollout.availableReplicas}/{rollout.desiredReplicas} ready</b></span><span>reason: <b className="text-slate-200">{rollout.reason || "unknown"}</b></span></div></button>
{selected === rollout.id && <div className="mt-4 border-t border-white/5 pt-3 text-xs text-slate-400"><div>Started {formatRelativeTime(rollout.startedAt / 1e6)}{rollout.completedAt ? ` · completed ${formatRelativeTime(rollout.completedAt / 1e6)}` : ""}</div><div className="mt-2 text-slate-500">{rollout.message || "No rollout message reported."}</div>{data && <div className="mt-3 rounded-lg bg-black/20 p-3"><div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-slate-500"><Activity className="h-3 w-3" /> Prometheus error-rate points</div>{!data.configured ? <div className="mt-2 text-slate-600">Prometheus is not configured.</div> : data.points.length === 0 ? <div className="mt-2 text-slate-600">No matching Prometheus series.</div> : <div className="mt-2 flex flex-wrap gap-2">{data.points.slice(-12).map((point) => <span key={`${point.timestamp}-${point.value}`} className="rounded bg-sky-400/10 px-2 py-1 font-mono text-[10px] text-sky-300">{new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: {point.value}</span>)}</div>}</div>}</div>}
</div>
})}</div>}
</div>
}
+504
View File
@@ -0,0 +1,504 @@
import React, { useState, useRef } from "react";
import {
Search,
Radar,
Play,
Square,
Copy,
Check,
ExternalLink,
} from "lucide-react";
import { authFetch } from "../lib/auth";
import { useObservabilityData } from "../hooks/useObservabilityData";
interface SearchResultEntry {
type: "traces" | "metrics" | "logs" | string;
timestamp: number;
data: any;
}
export const SearchAndWatcher: React.FC = () => {
const { setActiveTraceId, setActiveTab } = useObservabilityData();
// Search state
const [searchQuery, setSearchQuery] = useState("");
const [searchType, setSearchType] = useState<string>("");
const [searchLimit, setSearchLimit] = useState<number>(100);
const [searchTimeRange, setSearchTimeRange] = useState<string>("24h");
const [searchResults, setSearchResults] = useState<SearchResultEntry[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
// Watcher state
const [watchQuery, setWatchQuery] = useState("");
const [watchType, setWatchType] = useState<string>("");
const [watchTimeout, setWatchTimeout] = useState<number>(30);
const [isWatching, setIsWatching] = useState(false);
const [caughtSignal, setCaughtSignal] = useState<SearchResultEntry | null>(null);
const [watchStatus, setWatchStatus] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// UI helpers
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 1500);
};
const handleRunSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setSearchLoading(true);
setSearchError(null);
try {
const params = new URLSearchParams();
if (searchQuery.trim()) params.set("q", searchQuery.trim());
if (searchType) params.set("type", searchType);
params.set("limit", String(searchLimit));
if (searchTimeRange !== "all") {
const msMap: Record<string, number> = {
"1h": 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
const ms = msMap[searchTimeRange] || 24 * 60 * 60 * 1000;
const sinceNano = BigInt(Date.now() - ms) * 1_000_000n;
params.set("since", sinceNano.toString());
}
const res = await authFetch(`/api/history/search?${params.toString()}`);
if (!res.ok) throw new Error(`Search failed: HTTP ${res.status}`);
const data = await res.json();
setSearchResults(Array.isArray(data) ? data : []);
} catch (err) {
setSearchError(err instanceof Error ? err.message : String(err));
} finally {
setSearchLoading(false);
}
};
const startWatcher = async () => {
if (isWatching) return;
setIsWatching(true);
setCaughtSignal(null);
setWatchStatus("Trap armed. Waiting for telemetry match...");
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const params = new URLSearchParams();
if (watchQuery.trim()) params.set("q", watchQuery.trim());
if (watchType) params.set("type", watchType);
params.set("timeout", String(watchTimeout));
const res = await authFetch(`/api/history/wait?${params.toString()}`, {
signal: controller.signal,
});
if (!res.ok) {
if (res.status === 504) {
setWatchStatus(`Timeout reached (${watchTimeout}s) without match.`);
} else {
setWatchStatus(`Error: HTTP ${res.status}`);
}
return;
}
const signal = await res.json();
setCaughtSignal(signal);
setWatchStatus("Target signal trapped successfully!");
} catch (err: any) {
if (err.name === "AbortError") {
setWatchStatus("Trap disarmed by user.");
} else {
setWatchStatus(`Watcher error: ${err.message || String(err)}`);
}
} finally {
setIsWatching(false);
abortControllerRef.current = null;
}
};
const cancelWatcher = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsWatching(false);
};
// Helper to extract trace ID from an entry
const extractTraceId = (entry: SearchResultEntry): string | null => {
const raw = JSON.stringify(entry.data);
const match = raw.match(/"traceId":"([a-f0-9]{16,32})"/i);
return match ? match[1] : null;
};
return (
<div className="space-y-8 max-w-6xl font-body">
{/* Top Banner */}
<div className="rounded-2xl border border-sky-500/20 bg-gradient-to-r from-sky-950/40 via-slate-900/60 to-slate-950/80 p-5 shadow-xl backdrop-blur-md">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-sky-500/20 text-sky-400">
<Radar className="h-4 w-4 animate-pulse" />
</div>
<h2 className="text-base font-bold text-white tracking-tight">
Archive Search &amp; Live Signal Trap
</h2>
</div>
<p className="text-xs text-slate-400 max-w-2xl leading-relaxed">
Expose deep telemetry data beyond the browser buffer. Query full ClickHouse &amp; persistent storage logs/traces, or arm an OTLP signal trap to catch anomalies as they hit the server.
</p>
</div>
</div>
</div>
{/* SECTION 1: Live Signal Trap */}
<section className="rounded-2xl border border-white/10 bg-slate-950/40 p-5 shadow-xl space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-3">
<div className="flex items-center gap-2">
<Radar className="h-4 w-4 text-violet-400" />
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
Live Signal Trap (/api/history/wait)
</h3>
</div>
<span className="text-[11px] font-mono text-slate-500">
Long-poll subscriber for active debugging
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
<div className="md:col-span-6">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Target Keyword / Pattern / Trace ID
</label>
<div className="relative flex items-center">
<input
type="text"
value={watchQuery}
onChange={(e) => setWatchQuery(e.target.value)}
placeholder="e.g. 'error', '500', 'db_timeout', or service name..."
disabled={isWatching}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white placeholder-slate-500 focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
/>
</div>
</div>
<div className="md:col-span-3">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Signal Type
</label>
<select
value={watchType}
onChange={(e) => setWatchType(e.target.value)}
disabled={isWatching}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
>
<option value="">Any Telemetry</option>
<option value="traces">Traces Only</option>
<option value="logs">Logs Only</option>
<option value="metrics">Metrics Only</option>
</select>
</div>
<div className="md:col-span-3 flex items-end gap-2">
<div className="flex-1">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Timeout
</label>
<select
value={watchTimeout}
onChange={(e) => setWatchTimeout(Number(e.target.value))}
disabled={isWatching}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
>
<option value={15}>15 seconds</option>
<option value={30}>30 seconds</option>
<option value={60}>60 seconds</option>
</select>
</div>
{isWatching ? (
<button
type="button"
onClick={cancelWatcher}
className="h-9 px-4 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-mono text-xs font-bold transition-colors flex items-center gap-1.5 shadow-lg shadow-rose-500/20"
>
<Square className="h-3.5 w-3.5 fill-current" />
<span>Cancel</span>
</button>
) : (
<button
type="button"
onClick={startWatcher}
className="h-9 px-4 rounded-xl bg-violet-500 hover:bg-violet-600 text-white font-mono text-xs font-bold transition-colors flex items-center gap-1.5 shadow-lg shadow-violet-500/20"
>
<Play className="h-3.5 w-3.5 fill-current" />
<span>Arm Trap</span>
</button>
)}
</div>
</div>
{/* Watcher Status or Live Trigger Output */}
{watchStatus && (
<div
className={`rounded-xl p-3 text-xs font-mono flex items-center justify-between border ${
isWatching
? "bg-violet-500/10 border-violet-500/30 text-violet-300 animate-pulse"
: caughtSignal
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-300"
: "bg-slate-900/60 border-white/5 text-slate-400"
}`}
>
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
isWatching ? "bg-violet-400 animate-ping" : caughtSignal ? "bg-emerald-400" : "bg-slate-500"
}`}
/>
<span>{watchStatus}</span>
</div>
</div>
)}
{/* Captured Signal Preview */}
{caughtSignal && (
<div className="rounded-xl border border-emerald-500/20 bg-black/40 p-4 space-y-2 animate-in fade-in duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 px-2 py-0.5 text-[10px] font-bold uppercase">
{caughtSignal.type}
</span>
<span className="font-mono text-xs text-slate-300">
Captured at {new Date(caughtSignal.timestamp / 1_000_000).toLocaleTimeString()}
</span>
</div>
{extractTraceId(caughtSignal) && (
<button
onClick={() => {
const traceId = extractTraceId(caughtSignal);
if (traceId) {
setActiveTraceId(traceId);
setActiveTab("tab1");
}
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-sky-500 px-3 py-1 text-[11px] font-bold text-slate-950 hover:bg-sky-400 transition-colors"
>
<ExternalLink className="h-3 w-3" />
Jump to Captured Trace
</button>
)}
</div>
<pre className="max-h-60 overflow-auto rounded-lg border border-white/5 bg-slate-950 p-3 font-mono text-[11px] text-slate-300">
<code>{JSON.stringify(caughtSignal.data, null, 2)}</code>
</pre>
</div>
)}
</section>
{/* SECTION 2: Persistent Storage Search */}
<section className="rounded-2xl border border-white/10 bg-slate-950/40 p-5 shadow-xl space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-3">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-sky-400" />
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
Query Persistent Telemetry Store (/api/history/search)
</h3>
</div>
<span className="text-[11px] font-mono text-slate-500">
Searches full database history beyond active memory
</span>
</div>
<form onSubmit={handleRunSearch} className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-4">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Search Query
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Free-text substring in telemetry JSON..."
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none font-mono"
/>
</div>
<div className="md:col-span-2">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Type
</label>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value)}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-sky-500/50 focus:outline-none font-mono"
>
<option value="">All Types</option>
<option value="traces">Traces</option>
<option value="logs">Logs</option>
<option value="metrics">Metrics</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Time Window
</label>
<select
value={searchTimeRange}
onChange={(e) => setSearchTimeRange(e.target.value)}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-sky-500/50 focus:outline-none font-mono"
>
<option value="1h">Last 1 Hour</option>
<option value="6h">Last 6 Hours</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="all">All Storage</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block font-mono text-[10px] text-slate-400 uppercase tracking-wider mb-1">
Limit
</label>
<select
value={searchLimit}
onChange={(e) => setSearchLimit(Number(e.target.value))}
className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-sky-500/50 focus:outline-none font-mono"
>
<option value={50}>50 entries</option>
<option value={100}>100 entries</option>
<option value={250}>250 entries</option>
<option value={500}>500 entries</option>
</select>
</div>
<div className="md:col-span-2 flex items-end">
<button
type="submit"
disabled={searchLoading}
className="w-full h-9 rounded-xl bg-sky-500 hover:bg-sky-400 text-slate-950 font-mono text-xs font-bold transition-colors flex items-center justify-center gap-1.5 shadow-lg shadow-sky-500/20 disabled:opacity-50"
>
<Search className="h-3.5 w-3.5" />
<span>{searchLoading ? "Searching..." : "Search"}</span>
</button>
</div>
</div>
</form>
{searchError && (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 p-3 text-xs font-mono text-rose-300">
{searchError}
</div>
)}
{/* Results List */}
{searchResults !== null && (
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between text-[11px] font-mono text-slate-400 border-b border-white/5 pb-2">
<span>Found {searchResults.length} persistent storage matches</span>
</div>
{searchResults.length === 0 ? (
<div className="py-12 text-center text-sm text-slate-500 font-mono">
No telemetry entries matched your search criteria in the selected time range.
</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
{searchResults.map((entry, idx) => {
const traceId = extractTraceId(entry);
const isExpanded = expandedIndex === idx;
const timeFormatted = entry.timestamp
? new Date(entry.timestamp / 1_000_000).toLocaleString()
: "—";
return (
<div
key={idx}
className="rounded-xl border border-white/5 bg-black/30 p-3 text-xs font-mono transition-colors hover:border-white/15 space-y-2"
>
<div
onClick={() => setExpandedIndex(isExpanded ? null : idx)}
className="flex cursor-pointer items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
entry.type === "traces"
? "bg-sky-500/20 text-sky-300 border border-sky-500/30"
: entry.type === "logs"
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
}`}
>
{entry.type}
</span>
<span className="text-slate-400 text-[11px]">{timeFormatted}</span>
{traceId && (
<span className="text-sky-400 font-bold">
trace:{traceId.slice(0, 8)}...
</span>
)}
</div>
<div className="flex items-center gap-2">
{traceId && (
<button
onClick={(e) => {
e.stopPropagation();
setActiveTraceId(traceId);
setActiveTab("tab1");
}}
className="rounded bg-sky-500/10 border border-sky-500/30 px-2 py-0.5 text-[10px] text-sky-300 hover:bg-sky-500/20 transition-colors"
>
Open Trace
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(JSON.stringify(entry.data, null, 2), `res-${idx}`);
}}
className="p-1 text-slate-500 hover:text-white transition-colors"
title="Copy JSON"
>
{copiedId === `res-${idx}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
{isExpanded && (
<pre className="mt-2 max-h-56 overflow-auto rounded-lg border border-white/5 bg-slate-950 p-2.5 text-[10px] text-slate-300 leading-relaxed">
<code>{JSON.stringify(entry.data, null, 2)}</code>
</pre>
)}
</div>
);
})}
</div>
)}
</div>
)}
</section>
</div>
);
};
export default SearchAndWatcher;
+495
View File
@@ -0,0 +1,495 @@
import { useMemo, useState } from "react";
import { Layers } from "lucide-react";
import { formatDuration } from "../lib/timeUtils";
export interface GraphSpan {
spanId: string;
parentSpanId?: string;
service: string;
duration: number;
hasError?: boolean;
}
interface ServiceNode {
id: string;
name: string;
spanCount: number;
errorCount: number;
totalDuration: number;
layer: number;
x: number;
y: number;
}
interface ServiceEdge {
id: string;
source: string;
target: string;
callCount: number;
avgDuration: number;
hasError: boolean;
}
interface ServiceGraphProps {
spans: GraphSpan[];
onSelectService?: (serviceName: string | null) => void;
selectedService?: string | null;
}
export const ServiceGraph = ({
spans,
onSelectService,
selectedService,
}: ServiceGraphProps) => {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const { nodes, edges, width, height } = useMemo(() => {
// 1. Map spans by ID for parent lookup
const spanMap = new Map<string, GraphSpan>();
spans.forEach((s) => spanMap.set(s.spanId, s));
// 2. Aggregate service metrics
const serviceStats = new Map<
string,
{ spanCount: number; errorCount: number; totalDuration: number }
>();
spans.forEach((span) => {
const current = serviceStats.get(span.service) || {
spanCount: 0,
errorCount: 0,
totalDuration: 0,
};
current.spanCount += 1;
if (span.hasError) current.errorCount += 1;
current.totalDuration += span.duration;
serviceStats.set(span.service, current);
});
// 3. Aggregate cross-service edges
const edgeMap = new Map<
string,
{ source: string; target: string; count: number; totalDur: number; errors: number }
>();
spans.forEach((span) => {
if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
const parentSpan = spanMap.get(span.parentSpanId)!;
if (parentSpan.service !== span.service) {
const edgeKey = `${parentSpan.service}->${span.service}`;
const currentEdge = edgeMap.get(edgeKey) || {
source: parentSpan.service,
target: span.service,
count: 0,
totalDur: 0,
errors: 0,
};
currentEdge.count += 1;
currentEdge.totalDur += span.duration;
if (span.hasError) currentEdge.errors += 1;
edgeMap.set(edgeKey, currentEdge);
}
}
});
const uniqueServices = Array.from(serviceStats.keys());
// 4. Determine layer depth for each service (Topological ordering)
const inDegree = new Map<string, number>();
uniqueServices.forEach((s) => inDegree.set(s, 0));
edgeMap.forEach((edge) => {
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
});
const layers = new Map<string, number>();
// Roots have 0 in-degree
const queue: string[] = [];
uniqueServices.forEach((s) => {
if ((inDegree.get(s) || 0) === 0) {
layers.set(s, 0);
queue.push(s);
}
});
// If there's a cycle or no zero-in-degree root, fallback to first service
if (queue.length === 0 && uniqueServices.length > 0) {
layers.set(uniqueServices[0], 0);
queue.push(uniqueServices[0]);
}
while (queue.length > 0) {
const current = queue.shift()!;
const currentLayer = layers.get(current) || 0;
edgeMap.forEach((edge) => {
if (edge.source === current) {
const targetLayer = Math.max(layers.get(edge.target) || 0, currentLayer + 1);
layers.set(edge.target, targetLayer);
if (!queue.includes(edge.target)) {
queue.push(edge.target);
}
}
});
}
// Ensure all services have a layer
uniqueServices.forEach((s) => {
if (!layers.has(s)) layers.set(s, 0);
});
// Group services by layer
const layerGroups = new Map<number, string[]>();
uniqueServices.forEach((s) => {
const l = layers.get(s) || 0;
const group = layerGroups.get(l) || [];
group.push(s);
layerGroups.set(l, group);
});
const totalLayers = Math.max(1, layerGroups.size);
const nodeWidth = 200;
const nodeHeight = 84;
const xGap = 160;
const yGap = 36;
let maxNodesInLayer = 1;
layerGroups.forEach((group) => {
if (group.length > maxNodesInLayer) maxNodesInLayer = group.length;
});
const canvasWidth = Math.max(700, totalLayers * (nodeWidth + xGap));
const canvasHeight = Math.max(300, maxNodesInLayer * (nodeHeight + yGap) + 60);
// Compute positions
const computedNodes: ServiceNode[] = [];
const nodePosMap = new Map<string, { x: number; y: number }>();
Array.from(layerGroups.keys())
.sort((a, b) => a - b)
.forEach((layerIdx) => {
const group = layerGroups.get(layerIdx) || [];
const layerX = 40 + layerIdx * (nodeWidth + xGap);
const groupHeight = group.length * nodeHeight + (group.length - 1) * yGap;
const startY = Math.max(40, (canvasHeight - groupHeight) / 2);
group.forEach((serviceName, idxInGroup) => {
const layerY = startY + idxInGroup * (nodeHeight + yGap);
const stats = serviceStats.get(serviceName)!;
computedNodes.push({
id: serviceName,
name: serviceName,
spanCount: stats.spanCount,
errorCount: stats.errorCount,
totalDuration: stats.totalDuration,
layer: layerIdx,
x: layerX,
y: layerY,
});
nodePosMap.set(serviceName, { x: layerX, y: layerY });
});
});
// Computed edges with coordinates
const computedEdges: ServiceEdge[] = [];
edgeMap.forEach((edge, key) => {
computedEdges.push({
id: key,
source: edge.source,
target: edge.target,
callCount: edge.count,
avgDuration: edge.totalDur / edge.count,
hasError: edge.errors > 0,
});
});
return {
nodes: computedNodes,
edges: computedEdges,
width: canvasWidth,
height: canvasHeight,
};
}, [spans]);
if (nodes.length === 0) {
return (
<div className="py-12 text-center text-sm text-slate-500">
No service nodes available to visualize.
</div>
);
}
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
return (
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60 p-4 shadow-2xl backdrop-blur-md">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4 border-b border-white/5 pb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-sky-400" />
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">
Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links)
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-400">
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400" /> Healthy
</span>
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-rose-400" /> Errors
</span>
{selectedService && (
<button
onClick={() => onSelectService?.(null)}
className="ml-2 rounded-lg bg-sky-500/20 px-2 py-0.5 text-sky-300 hover:bg-sky-500/30 transition-all font-semibold"
>
Clear filter ({selectedService})
</button>
)}
</div>
</div>
<div className="overflow-x-auto">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="mx-auto select-none"
>
<defs>
<marker
id="arrow-normal"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="#38bdf8" />
</marker>
<marker
id="arrow-error"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="#f43f5e" />
</marker>
<linearGradient id="edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.8" />
<stop offset="100%" stopColor="#818cf8" stopOpacity="0.8" />
</linearGradient>
<linearGradient id="edge-error-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f43f5e" stopOpacity="0.9" />
<stop offset="100%" stopColor="#fb7185" stopOpacity="0.9" />
</linearGradient>
</defs>
{/* Render Connecting Edges */}
{edges.map((edge) => {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) return null;
const startX = src.x + 200;
const startY = src.y + 42;
const endX = tgt.x;
const endY = tgt.y + 42;
const dx = endX - startX;
const cpx1 = startX + dx * 0.45;
const cpy1 = startY;
const cpx2 = endX - dx * 0.45;
const cpy2 = endY;
const pathD = `M ${startX} ${startY} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${endX} ${endY}`;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const isEdgeHighlighted =
hoveredNode === edge.source ||
hoveredNode === edge.target ||
selectedService === edge.source ||
selectedService === edge.target;
return (
<g key={edge.id} className="transition-opacity duration-300">
{/* Background path for hit testing */}
<path
d={pathD}
fill="none"
stroke="transparent"
strokeWidth={20}
className="cursor-pointer"
/>
{/* Visible animated curve */}
<path
d={pathD}
fill="none"
stroke={
edge.hasError
? "url(#edge-error-grad)"
: isEdgeHighlighted
? "#38bdf8"
: "rgba(56, 189, 248, 0.4)"
}
strokeWidth={isEdgeHighlighted ? 3 : 2}
strokeDasharray={edge.hasError ? "4 2" : undefined}
markerEnd={edge.hasError ? "url(#arrow-error)" : "url(#arrow-normal)"}
/>
{/* Edge Call Badge */}
<g transform={`translate(${midX - 35}, ${midY - 12})`}>
<rect
width={70}
height={22}
rx={6}
fill="#030712"
stroke={edge.hasError ? "rgba(244,63,94,0.4)" : "rgba(255,255,255,0.1)"}
strokeWidth={1}
/>
<text
x={35}
y={14}
textAnchor="middle"
fill={edge.hasError ? "#fb7185" : "#94a3b8"}
fontSize={9}
fontWeight="bold"
fontFamily="monospace"
>
{edge.callCount}x · {formatDuration(edge.avgDuration)}
</text>
</g>
</g>
);
})}
{/* Render Service Nodes */}
{nodes.map((node) => {
const isSelected = selectedService === node.name;
const isHovered = hoveredNode === node.name;
const hasError = node.errorCount > 0;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onClick={() => onSelectService?.(isSelected ? null : node.name)}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
className="cursor-pointer"
>
{/* Node Box */}
<rect
width={200}
height={84}
rx={16}
fill="#090d16"
stroke={
isSelected
? "#38bdf8"
: hasError
? "#f43f5e"
: isHovered
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)"
}
strokeWidth={isSelected ? 2.5 : hasError ? 2 : 1}
filter={isSelected ? "drop-shadow(0 0 12px rgba(56,189,248,0.4))" : undefined}
/>
{/* Left Accent Stripe */}
<rect
x={0}
y={0}
width={6}
height={84}
rx={3}
fill={hasError ? "#f43f5e" : "#38bdf8"}
/>
{/* Service Name Header */}
<g transform="translate(16, 24)">
<text
x={0}
y={0}
fill="#ffffff"
fontSize={13}
fontWeight="bold"
fontFamily="sans-serif"
>
{node.name.length > 20 ? `${node.name.slice(0, 18)}...` : node.name}
</text>
</g>
{/* Spans Count & Latency Pills */}
<g transform="translate(16, 44)">
<text x={0} y={10} fill="#64748b" fontSize={10} fontFamily="monospace">
Spans: <tspan fill="#e2e8f0">{node.spanCount}</tspan>
</text>
<text x={75} y={10} fill="#64748b" fontSize={10} fontFamily="monospace">
Time: <tspan fill="#38bdf8">{formatDuration(node.totalDuration)}</tspan>
</text>
</g>
{/* Status Badges */}
<g transform="translate(16, 68)">
{hasError ? (
<g>
<rect width={80} height={16} rx={4} fill="rgba(244,63,94,0.15)" />
<text
x={40}
y={11}
textAnchor="middle"
fill="#fb7185"
fontSize={9}
fontWeight="bold"
>
{node.errorCount} Error{node.errorCount > 1 ? "s" : ""}
</text>
</g>
) : (
<g>
<rect width={60} height={16} rx={4} fill="rgba(16,185,129,0.15)" />
<text
x={30}
y={11}
textAnchor="middle"
fill="#34d399"
fontSize={9}
fontWeight="bold"
>
Healthy
</text>
</g>
)}
{isSelected && (
<g transform="translate(90, 0)">
<rect width={55} height={16} rx={4} fill="rgba(56,189,248,0.2)" />
<text
x={27.5}
y={11}
textAnchor="middle"
fill="#38bdf8"
fontSize={9}
fontWeight="bold"
>
Active
</text>
</g>
)}
</g>
</g>
);
})}
</svg>
</div>
{nodes.length === 1 && (
<div className="mt-3 text-center text-xs text-slate-500">
This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made.
</div>
)}
</div>
);
};
+112
View File
@@ -0,0 +1,112 @@
import { useState } from "react"
import { Check, Copy, ExternalLink, FileText, Globe } from "lucide-react"
import { useObservabilityData } from "../hooks/useObservabilityData"
const scriptSnippet = `<script src="https://otel.k6n.net/static/otel-rum.js"></script>
<script>
var rum = OtelRUM.init({
serviceName: "my-web-app",
baseUrl: "https://otel.k6n.net", // Target OTel collector (defaults to script origin)
});
// Optional: propagate trace context to your own API calls
fetch("/api/orders", { headers: rum.traceHeaders() });
// Optional: report a caught exception manually
try { risky(); } catch (err) { rum.captureException(err); }
// React render failures should be reported from an error boundary.
// componentDidCatch(error, info) {
// rum.captureException(error, { "react.component_stack": info.componentStack });
// }
</script>`
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false)
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard API unavailable; nothing to fall back to here
}
}}
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"
>
{copied ? <Check className="h-3 w-3 text-emerald-400" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</button>
)
}
export const SetupPanel = () => {
const { setActiveTab } = useObservabilityData()
return (
<div className="space-y-6 max-w-3xl">
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-5">
<div className="flex items-center justify-between gap-4">
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
<Globe className="h-3.5 w-3.5 text-violet-400" />
Browser instrumentation (otel-rum.js)
</span>
<CopyButton text={scriptSnippet} />
</div>
<p className="mt-2 text-[12px] text-slate-400">
Drop this into any page to capture uncaught errors, unhandled rejections and failed
resource loads as OTLP logs. Errors show up here under the{" "}
<span className="text-slate-200 font-semibold">Issues</span> tab tagged{" "}
<span className="text-violet-300 font-semibold">Frontend Error</span>, and correlate with
backend traces via <code className="text-sky-300">traceHeaders()</code>.
</p>
<pre className="mt-3 overflow-x-auto rounded-lg border border-white/5 bg-black/50 p-3 text-[11px] leading-relaxed text-slate-300">
<code>{scriptSnippet}</code>
</pre>
<div className="mt-4 flex items-center gap-3">
<a
href="/static/otel-rum.js"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-sky-400 hover:text-sky-300"
>
<ExternalLink className="h-3 w-3" />
View raw script at /static/otel-rum.js
</a>
<span className="text-slate-600">•</span>
<button
type="button"
onClick={() => setActiveTab("tab7")}
className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300"
>
<Globe className="h-3 w-3" />
Open Frontend & RUM Explorer →
</button>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-5">
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
<FileText className="h-3.5 w-3.5 text-sky-400" />
Agent instructions
</span>
<p className="mt-2 text-[12px] text-slate-400">
A single doc an AI coding agent can fetch before touching this API - endpoints, query
params and response shapes for traces, logs, metrics and issues.
</p>
<a
href="/agents_example.md"
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-[11px] font-semibold text-sky-400 hover:text-sky-300"
>
<ExternalLink className="h-3 w-3" />
Open /agents_example.md
</a>
</div>
</div>
)
}
export default SetupPanel
+344
View File
@@ -0,0 +1,344 @@
import React from "react";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { useIssues } from "../hooks/useIssues";
import { useStatsAvailability } from "../hooks/useStats";
export const Sidebar: React.FC = () => {
const {
activeTab,
setActiveTab,
traces,
logs,
metrics,
isConnected,
uniqueServices,
serviceHealth,
selectedServices,
toggleService,
resetFilters,
} = useObservabilityData();
const { issues } = useIssues();
const statsAvailable = useStatsAvailability();
// Active open issues count
const openIssuesCount = React.useMemo(() => {
return issues.filter((i) => !i.status || i.status === "open").length;
}, [issues]);
const errorLogCount = React.useMemo(() => {
let errs = 0;
logs.forEach((log) => {
(log.scopeLogs ?? []).forEach((sl) => {
(sl.logRecords ?? []).forEach((r) => {
if ((r.severityNumber ?? 0) >= 17) errs += 1;
});
});
});
return errs;
}, [logs]);
const totalSpans = React.useMemo(() => {
let count = 0;
traces.forEach((t) => {
(t.resourceSpans ?? []).forEach((rs) => {
(rs.scopeSpans ?? []).forEach((ss) => {
count += (ss.spans ?? []).length;
});
});
});
return count;
}, [traces]);
const { frontendCount, frontendErrorCount } = React.useMemo(() => {
let count = 0;
let errors = 0;
logs.forEach((log) => {
(log.scopeLogs ?? []).forEach((sl) => {
(sl.logRecords ?? []).forEach((r) => {
const attrs = r.attributes ?? [];
const source = attrs.find((a) => a.key === "source")?.value.stringValue;
const isRum = sl.scope?.name === "otel-rum";
const vital = attrs.find((a) => a.key === "rum.vital")?.value.stringValue;
const isFrontend = source === "frontend" || isRum || Boolean(vital);
if (isFrontend) {
count++;
if ((r.severityNumber ?? 0) >= 17 || attrs.some((a) => a.key === "exception.type")) {
errors++;
}
}
});
});
});
return { frontendCount: count, frontendErrorCount: errors };
}, [logs]);
const navItems = [
{
tab: "tab0",
label: "Issues & Anomalies",
icon: "error",
badge: openIssuesCount > 0 ? `${openIssuesCount} active` : `${issues.length} total`,
badgeType: openIssuesCount > 0 ? ("error" as const) : ("outline" as const),
},
{
tab: "tab8",
label: "Similarity Patterns",
icon: "hub",
badge: undefined,
badgeType: "outline" as const,
},
{
tab: "tab9",
label: "Cluster Rollouts",
icon: "cloud_sync",
badge: undefined,
badgeType: "outline" as const,
},
{
tab: "tab1",
label: "Traces & Spans",
icon: "account_tree",
badge: totalSpans > 0 ? `${totalSpans} spans` : `${traces.length} traces`,
badgeType: totalSpans > 0 ? ("secondary" as const) : ("outline" as const),
},
{
tab: "tab3",
label: "Live Log Stream",
icon: "terminal",
badge: errorLogCount > 0 ? `${errorLogCount} err` : `${logs.length} logs`,
badgeType: errorLogCount > 0 ? ("error" as const) : ("secondary" as const),
},
{
tab: "tab2",
label: "Metrics & APM",
icon: "query_stats",
badge: `${metrics.length} metrics`,
badgeType: "outline" as const,
},
{
tab: "tab7",
label: "Frontend & RUM",
icon: "devices",
badge:
frontendCount > 0
? frontendErrorCount > 0
? `${frontendErrorCount} err`
: `${frontendCount} sig`
: "RUM",
badgeType: frontendErrorCount > 0 ? ("error" as const) : ("secondary" as const),
},
...(statsAvailable
? [
{
tab: "tab5",
label: "Historical Trends",
icon: "trending_up",
badge: "ClickHouse",
badgeType: "outline" as const,
},
]
: []),
{
tab: "tab6",
label: "Archive & Trap",
icon: "radar",
badge: "Deep Query",
badgeType: "secondary" as const,
},
{
tab: "tab4",
label: "Setup & Agent API",
icon: "build",
badge: "RUM / Doc",
badgeType: "outline" as const,
},
];
return (
<aside className="fixed left-0 top-0 h-full w-72 bg-surface-container-lowest z-50 flex flex-col justify-between select-none shadow-[0_1px_8px_rgba(0,0,0,0.04)] border-r border-surface-container/60">
<div className="flex flex-col flex-1 overflow-y-auto">
{/* Brand Header */}
<div className="h-14 px-4 flex items-center justify-between bg-surface-container-lowest border-b border-surface-container/40">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-container to-secondary text-on-primary font-black text-sm shadow-md">
<span className="material-symbols-outlined text-[18px]">hub</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-headline font-semibold text-base tracking-tight text-on-surface leading-none">
HyperScope
</span>
<span className="font-mono text-[10px] px-1 py-0.5 rounded bg-surface-container-high text-primary-container leading-none uppercase font-bold">
OTel
</span>
</div>
<span className="font-mono text-[11px] text-outline mt-0.5 leading-none">
Realtime Observability
</span>
</div>
</div>
<div
className={`flex items-center gap-1 px-2 py-0.5 rounded-full font-mono text-[9px] font-bold uppercase tracking-wider ${
isConnected
? "bg-secondary-container/20 text-secondary border border-secondary-container/40"
: "bg-amber-500/15 text-amber-400 border border-amber-500/30 animate-pulse"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
isConnected ? "bg-secondary animate-pulse" : "bg-amber-400"
}`}
/>
<span>{isConnected ? "LIVE" : "RECONNECT"}</span>
</div>
</div>
{/* Telemetry Core Navigation */}
<div className="px-3 mt-3">
<div className="px-2 pb-1.5 font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
Telemetry Views
</div>
<nav className="space-y-1">
{navItems.map((item) => {
const isActive = activeTab === item.tab;
return (
<button
key={item.tab}
type="button"
onClick={() => setActiveTab(item.tab)}
className={`w-full flex items-center justify-between px-2.5 py-2 rounded-lg transition-colors group text-left ${
isActive
? "bg-surface-container-high text-primary font-semibold shadow-sm"
: "text-on-surface-variant hover:bg-surface-container hover:text-on-surface"
}`}
>
<div className="flex items-center gap-2.5">
<span
className={`material-symbols-outlined text-[18px] transition-colors ${
isActive
? "text-primary-container"
: "text-outline group-hover:text-primary-container"
}`}
>
{item.icon}
</span>
<span className="text-xs font-body font-medium">{item.label}</span>
</div>
{item.badge && (
<span
className={`font-mono text-[10px] px-1.5 py-0.5 rounded font-medium ${
item.badgeType === "error"
? "bg-error-container text-on-error-container font-semibold"
: item.badgeType === "secondary"
? "bg-surface-container text-secondary"
: "bg-surface-container text-outline"
}`}
>
{item.badge}
</span>
)}
</button>
);
})}
</nav>
</div>
{/* Real Discovered Services Fleet List */}
<div className="px-3 mt-5 flex-1">
<div className="px-2 pb-1.5 flex items-center justify-between">
<span className="font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
Discovered Services ({uniqueServices.length})
</span>
{selectedServices.size > 0 && (
<button
type="button"
onClick={resetFilters}
className="font-mono text-[10px] text-primary-container hover:underline"
>
Clear
</button>
)}
</div>
<div className="space-y-1 font-mono text-xs">
{uniqueServices.length === 0 ? (
<div className="px-2.5 py-3 text-center rounded-lg bg-surface-container-low/50 text-[11px] text-outline italic">
Listening for service signals...
</div>
) : (
uniqueServices.map((service) => {
const isSelected = selectedServices.has(service);
const health = serviceHealth[service];
const hasErrors = health?.hasErrors || false;
const errorCount = health?.errorCount || 0;
return (
<button
key={service}
type="button"
onClick={() => toggleService(service)}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-left transition-colors ${
isSelected
? "bg-primary-container/20 text-primary-container border border-primary-container/40 font-semibold"
: "text-on-surface-variant hover:bg-surface-container hover:text-on-surface"
}`}
>
<div className="flex items-center gap-2 min-w-0 pr-1">
<span
className={`w-2 h-2 rounded-full shrink-0 ${
hasErrors
? "bg-error animate-pulse"
: isSelected
? "bg-primary-container"
: "bg-emerald-400/80"
}`}
title={hasErrors ? `${errorCount} errors in active window` : "Healthy"}
/>
<span className="truncate text-xs">{service}</span>
</div>
{hasErrors ? (
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-error-container text-on-error-container font-semibold shrink-0">
{errorCount} err
</span>
) : (
<span className="font-mono text-[10px] text-outline shrink-0">
{health?.totalSignals ?? 0}
</span>
)}
</button>
);
})
)}
</div>
</div>
</div>
{/* Receiver Endpoints & WebSocket Health Status */}
<div className="p-3 bg-surface-container-lowest border-t border-surface-container/40">
<div className="p-2.5 rounded-lg bg-surface-container-low flex flex-col gap-1.5 border border-surface-container/40 font-mono text-[11px]">
<div className="flex items-center justify-between">
<span className="text-outline">Receiver Ports</span>
<span className="text-on-surface font-semibold">4317 / 4318</span>
</div>
<div className="flex items-center justify-between">
<span className="text-outline">Web UI &amp; API</span>
<span className="text-on-surface font-semibold">:8080</span>
</div>
<div className="flex items-center justify-between pt-1 border-t border-surface-container/40">
<span className="text-outline">Live Pipeline</span>
<span
className={`font-semibold ${
isConnected ? "text-secondary" : "text-amber-400"
}`}
>
{isConnected ? "Connected" : "Reconnecting..."}
</span>
</div>
</div>
</div>
</aside>
);
};
@@ -0,0 +1,582 @@
import React, { useMemo, useState } from "react";
import type { LogEntry, LogRecord, OtelAttribute } from "../lib/types";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { formatAttributeValue, getServiceName } from "../lib/otel-utils";
interface FlattenedRecord extends LogRecord {
scope?: string;
resourceAttributes: OtelAttribute[];
serviceName: string;
}
export const StructuredLogTerminal: React.FC<{ logs: LogEntry[] }> = ({ logs }) => {
const { setActiveTraceId, setActiveSpanId, setActiveTab, selectedServices, toggleService } =
useObservabilityData();
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
const [streamFilter, setStreamFilter] = useState("");
const [wrapText, setWrapText] = useState(false);
const [cleared, setCleared] = useState(false);
// Severity filters
const [selectedSeverities, setSelectedSeverities] = useState<Record<string, boolean>>({
FATAL: true,
ERROR: true,
WARN: true,
INFO: true,
DEBUG: false,
});
const toggleSeverity = (sev: string) => {
setSelectedSeverities((prev) => ({ ...prev, [sev]: !prev[sev] }));
};
// Flatten all log records
const allRecords: FlattenedRecord[] = useMemo(() => {
if (cleared) return [];
return logs.flatMap((log) =>
(log.scopeLogs ?? []).flatMap((scopeLog) =>
(scopeLog.logRecords ?? []).map((record) => {
const serviceName = getServiceName(
log.resource?.attributes ?? [],
record.attributes ?? [],
);
return {
...record,
scope: scopeLog.scope?.name,
resourceAttributes: log.resource?.attributes ?? [],
serviceName,
};
}),
),
);
}, [logs, cleared]);
// Counts by severity
const severityCounts = useMemo(() => {
const counts = { FATAL: 0, ERROR: 0, WARN: 0, INFO: 0, DEBUG: 0 };
allRecords.forEach((r) => {
const num = r.severityNumber ?? 0;
if (num >= 21) counts.FATAL++;
else if (num >= 17) counts.ERROR++;
else if (num >= 13) counts.WARN++;
else if (num >= 9) counts.INFO++;
else counts.DEBUG++;
});
return counts;
}, [allRecords]);
// Service facet counts
const serviceCounts = useMemo(() => {
const counts = new Map<string, number>();
allRecords.forEach((r) => {
counts.set(r.serviceName, (counts.get(r.serviceName) || 0) + 1);
});
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
}, [allRecords]);
// Real diagnostic heuristic calculated from actual logs
const errorHeuristic = useMemo(() => {
const errorRecords = allRecords.filter((r) => (r.severityNumber ?? 0) >= 17);
if (errorRecords.length === 0) {
return {
text: serviceCounts.length > 0
? `All ${serviceCounts.length} active service(s) nominal. 0 error logs recorded in current buffer.`
: "No log signals received yet. Waiting for OTLP logs...",
hasErrors: false,
};
}
const errByService: Record<string, number> = {};
errorRecords.forEach((r) => {
errByService[r.serviceName] = (errByService[r.serviceName] || 0) + 1;
});
const sorted = Object.entries(errByService).sort((a, b) => b[1] - a[1]);
const topService = sorted[0]?.[0] || "unknown";
const topCount = sorted[0]?.[1] || 0;
const pct = Math.round((topCount / errorRecords.length) * 100);
return {
text: `${pct}% of current ERROR logs (${topCount} of ${errorRecords.length}) originated in "${topService}".`,
hasErrors: true,
};
}, [allRecords, serviceCounts.length]);
// Filter records based on selected severities, services, and local stream search
const visibleRecords = useMemo(() => {
return allRecords.filter((r) => {
const num = r.severityNumber ?? 0;
let sevKey = "DEBUG";
if (num >= 21) sevKey = "FATAL";
else if (num >= 17) sevKey = "ERROR";
else if (num >= 13) sevKey = "WARN";
else if (num >= 9) sevKey = "INFO";
if (!selectedSeverities[sevKey]) return false;
if (streamFilter.trim()) {
const query = streamFilter.toLowerCase().trim();
const msg = (r.body?.stringValue ?? "").toLowerCase();
const srv = r.serviceName.toLowerCase();
const trace = (r.traceId ?? "").toLowerCase();
const attrs = (r.attributes ?? []).some(
(a) =>
a.key.toLowerCase().includes(query) ||
formatAttributeValue(a.value).toLowerCase().includes(query),
);
if (!msg.includes(query) && !srv.includes(query) && !trace.includes(query) && !attrs) {
return false;
}
}
return true;
});
}, [allRecords, selectedSeverities, streamFilter]);
const copyLogStream = () => {
if (visibleRecords.length === 0) return;
const excerpt = visibleRecords
.slice(0, 50)
.map((r) => {
const time = new Date(Number(BigInt(r.timeUnixNano || "0") / 1_000_000n)).toISOString();
return `[${time}] ${r.severityText || "LOG"} ${r.serviceName}: ${r.body?.stringValue ?? ""}`;
})
.join("\n");
navigator.clipboard.writeText(excerpt);
alert("Copied visible log excerpt to clipboard.");
};
const navigateToTraceSpan = (traceId: string, spanId?: string) => {
setActiveTraceId(traceId);
if (spanId) setActiveSpanId(spanId);
setActiveTab("tab1");
};
const formatIsoTimestamp = (nanoStr?: string) => {
if (!nanoStr) return "—";
const millis = Number(BigInt(nanoStr) / 1_000_000n);
return new Date(millis).toISOString();
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start font-body">
{/* LEFT PANEL: Faceted Filter Breakdown (3 Cols) */}
<aside className="lg:col-span-3 flex flex-col gap-4">
{/* Severity Breakdown Card */}
<div className="bg-surface-container-lowest p-4 rounded-xl shadow-md flex flex-col gap-2 border border-surface-container/50">
<div className="flex items-center justify-between pb-1 border-b border-surface-container/40">
<span className="font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
Severity Breakdown
</span>
<span className="font-mono text-xs text-outline">{allRecords.length} Total</span>
</div>
<div className="space-y-1">
{/* FATAL */}
<label className="flex items-center justify-between p-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container transition-colors cursor-pointer group">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedSeverities.FATAL}
onChange={() => toggleSeverity("FATAL")}
className="rounded bg-surface-container-high border-outline text-error focus:ring-0"
/>
<span className="font-mono text-xs text-error font-bold tracking-tight">FATAL</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-error-container text-on-error-container font-semibold">
{severityCounts.FATAL}
</span>
<span className="w-1.5 h-1.5 rounded-full bg-error" />
</div>
</label>
{/* ERROR */}
<label className="flex items-center justify-between p-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container transition-colors cursor-pointer group">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedSeverities.ERROR}
onChange={() => toggleSeverity("ERROR")}
className="rounded bg-surface-container-high border-outline text-error focus:ring-0"
/>
<span className="font-mono text-xs text-error font-medium">ERROR</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-error-container text-on-error-container font-semibold">
{severityCounts.ERROR}
</span>
<span className="w-1.5 h-1.5 rounded-full bg-error" />
</div>
</label>
{/* WARN */}
<label className="flex items-center justify-between p-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container transition-colors cursor-pointer group">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedSeverities.WARN}
onChange={() => toggleSeverity("WARN")}
className="rounded bg-surface-container-high border-outline text-primary-fixed-dim focus:ring-0"
/>
<span className="font-mono text-xs text-primary-fixed-dim">WARN</span>
</div>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-surface-container-high text-on-surface font-medium">
{severityCounts.WARN}
</span>
</label>
{/* INFO */}
<label className="flex items-center justify-between p-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container transition-colors cursor-pointer group">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedSeverities.INFO}
onChange={() => toggleSeverity("INFO")}
className="rounded bg-surface-container-high border-outline text-secondary focus:ring-0"
/>
<span className="font-mono text-xs text-secondary">INFO</span>
</div>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-surface-container-high text-on-surface-variant">
{severityCounts.INFO}
</span>
</label>
{/* DEBUG */}
<label className="flex items-center justify-between p-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container transition-colors cursor-pointer group">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedSeverities.DEBUG}
onChange={() => toggleSeverity("DEBUG")}
className="rounded bg-surface-container-high border-outline text-outline focus:ring-0"
/>
<span className="font-mono text-xs text-outline">DEBUG</span>
</div>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-surface-container-high text-outline">
{severityCounts.DEBUG}
</span>
</label>
</div>
</div>
{/* Service Facet Card */}
<div className="bg-surface-container-lowest p-4 rounded-xl shadow-md flex flex-col gap-2 border border-surface-container/50">
<div className="flex items-center justify-between pb-1 border-b border-surface-container/40">
<span className="font-mono text-[10px] text-outline uppercase tracking-wider font-semibold">
Service Name
</span>
<span className="material-symbols-outlined text-outline text-[16px]">filter_alt</span>
</div>
<div className="space-y-1 font-mono text-xs">
{serviceCounts.slice(0, 6).map(([serviceName, count]) => {
const isSelected = selectedServices.has(serviceName);
return (
<div
key={serviceName}
onClick={() => toggleService(serviceName)}
className={`flex items-center justify-between px-2.5 py-1.5 rounded-lg cursor-pointer transition-colors ${
isSelected
? "bg-surface-container text-primary-container font-semibold"
: "hover:bg-surface-container text-on-surface-variant"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
isSelected ? "bg-primary-container" : "bg-outline"
}`}
/>
<span className="truncate">{serviceName}</span>
</div>
<span className="font-mono text-[10px] text-outline">{count}</span>
</div>
);
})}
{serviceCounts.length === 0 && (
<p className="text-center py-2 text-outline text-xs italic">No services detected</p>
)}
</div>
</div>
{/* AI Root Cause Diagnostic Hint Box */}
<div className="bg-surface-container-low p-4 rounded-xl shadow-md flex flex-col gap-1.5 border border-surface-container/60">
<div className="flex items-center gap-2 text-primary-container">
<span className="material-symbols-outlined text-[18px]">psychology</span>
<span className="font-headline font-semibold text-xs leading-none">
Diagnostic Heuristic
</span>
</div>
<p className="font-body text-xs text-on-surface-variant leading-relaxed">
{errorHeuristic.text}
</p>
<div className="pt-1 flex items-center justify-between">
<button
onClick={() => {
setSelectedSeverities({ FATAL: true, ERROR: true, WARN: false, INFO: false, DEBUG: false });
}}
className="font-mono text-[10px] text-primary-container hover:underline uppercase flex items-center gap-1 font-semibold"
>
<span>Filter Only Error Signatures</span>
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</div>
</div>
</aside>
{/* RIGHT PANEL: High-Density Structured Live Log Stream (9 Cols) */}
<main className="lg:col-span-9 flex flex-col bg-surface-container-lowest rounded-xl shadow-md overflow-hidden border border-surface-container/50">
{/* Stream Controls Bar */}
<div className="flex flex-wrap items-center justify-between gap-2 p-2.5 bg-surface-container-low border-b border-surface-container/40">
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
<div className="relative flex-1">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-outline text-[16px] pointer-events-none">
filter_list
</span>
<input
type="text"
value={streamFilter}
onChange={(e) => setStreamFilter(e.target.value)}
placeholder="Filter current buffer (e.g. 'timeout', 'status:504')..."
className="w-full h-7 pl-8 pr-3 rounded bg-surface-container text-on-surface font-mono text-xs placeholder:text-outline focus:outline-none focus:bg-surface-container-high transition-colors border border-surface-container/50"
/>
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 font-mono text-xs text-outline cursor-pointer select-none">
<input
type="checkbox"
checked={wrapText}
onChange={(e) => setWrapText(e.target.checked)}
className="rounded bg-surface-container border-outline text-primary-container focus:ring-0"
/>
<span>Wrap Text</span>
</label>
<div className="h-3 w-px bg-surface-container-high" />
<button
onClick={() => setCleared(true)}
className="text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
title="Clear Buffer"
>
<span className="material-symbols-outlined text-[18px]">clear_all</span>
</button>
<button
onClick={copyLogStream}
className="text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
title="Copy Visible Buffer"
>
<span className="material-symbols-outlined text-[18px]">content_copy</span>
</button>
</div>
</div>
{/* Log Terminal Headers */}
<div className="grid grid-cols-12 px-4 py-1.5 bg-surface-container text-outline font-mono uppercase tracking-wider select-none text-[11px] border-b border-surface-container/60">
<div className="col-span-2">Timestamp (UTC)</div>
<div className="col-span-1">Level</div>
<div className="col-span-2">Service &amp; Trace</div>
<div className="col-span-7">Log Message &amp; Structured Payload</div>
</div>
{/* Realtime Log Entries List */}
<div className="divide-y divide-surface-container-high/40 font-mono text-xs max-h-[640px] overflow-y-auto select-text">
{visibleRecords.length === 0 ? (
<div className="p-8 text-center text-outline text-xs">
{cleared
? "Stream buffer cleared. Waiting for new telemetry records..."
: "No log records match the current filters."}
</div>
) : (
visibleRecords.map((record) => {
const num = record.severityNumber ?? 0;
let sevLabel = record.severityText?.toUpperCase() || "INFO";
let sevClass = "bg-surface-container-high text-secondary";
if (num >= 21) {
sevLabel = "FATAL";
sevClass = "bg-error text-on-error font-extrabold";
} else if (num >= 17) {
sevLabel = "ERROR";
sevClass = "bg-error-container text-on-error-container font-bold";
} else if (num >= 13) {
sevLabel = "WARN";
sevClass = "bg-surface-container-high text-primary-fixed-dim font-medium";
}
const rowId = record.uid;
const isExpanded = expandedRowId === rowId;
const isoTime = formatIsoTimestamp(record.timeUnixNano);
const bodyMessage = record.body?.stringValue ?? "(empty body)";
return (
<div
key={rowId}
className="flex flex-col bg-surface-container-lowest hover:bg-surface-container/30 transition-colors"
>
<div
onClick={() => setExpandedRowId(isExpanded ? null : rowId)}
className="grid grid-cols-12 px-4 py-2 items-start cursor-pointer group"
>
<div className="col-span-2 text-outline whitespace-nowrap pt-0.5 text-[11px]">
{isoTime}
</div>
<div className="col-span-1">
<span
className={`font-mono text-[10px] px-1.5 py-0.5 rounded tracking-tight ${sevClass}`}
>
{sevLabel}
</span>
</div>
<div className="col-span-2 flex flex-col gap-0.5 min-w-0 pr-2">
<span className="text-primary truncate font-medium">{record.serviceName}</span>
{record.traceId && (
<div className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-primary-container">
link
</span>
<button
onClick={(e) => {
e.stopPropagation();
navigateToTraceSpan(record.traceId!, record.spanId);
}}
className="text-primary-container hover:underline truncate font-mono text-[10px]"
>
{record.traceId.slice(0, 12)}
</button>
</div>
)}
</div>
<div className="col-span-7 flex items-start justify-between min-w-0">
<div
className={`text-on-surface font-normal ${
wrapText ? "break-words" : "truncate"
}`}
>
{num >= 17 && (
<span className="text-error font-medium mr-1">Exception:</span>
)}
{bodyMessage}
</div>
<span className="material-symbols-outlined text-outline text-[16px] group-hover:text-on-surface transition-transform shrink-0 ml-2">
{isExpanded ? "unfold_less" : "unfold_more"}
</span>
</div>
</div>
{/* Structured Payload Drawer (Expanded) */}
{isExpanded && (
<div className="px-4 py-3 bg-surface-container-low text-xs flex flex-col gap-2 border-t border-surface-container">
<div className="flex items-center justify-between border-b border-surface-container pb-1">
<span className="font-mono text-[10px] text-outline uppercase tracking-wider">
OpenTelemetry Attributes &amp; Resource Scope
</span>
{record.traceId && (
<button
onClick={() => {
navigator.clipboard.writeText(
`trace:${record.traceId} span:${record.spanId || ""}`,
);
alert("Copied trace context to clipboard.");
}}
className="flex items-center gap-1 font-mono text-[10px] text-primary-container hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">copy_all</span>
<span>Copy Trace Context</span>
</button>
)}
</div>
{/* Attributes Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 font-mono text-xs">
{record.spanId && (
<div className="p-1.5 rounded bg-surface-container flex flex-col">
<span className="text-outline text-[10px] uppercase">span_id</span>
<span className="text-primary-container truncate font-medium">
{record.spanId}
</span>
</div>
)}
{record.traceId && (
<div className="p-1.5 rounded bg-surface-container flex flex-col">
<span className="text-outline text-[10px] uppercase">trace_id</span>
<span className="text-on-surface truncate">{record.traceId}</span>
</div>
)}
{record.scope && (
<div className="p-1.5 rounded bg-surface-container flex flex-col">
<span className="text-outline text-[10px] uppercase">scope</span>
<span className="text-secondary truncate">{record.scope}</span>
</div>
)}
<div className="p-1.5 rounded bg-surface-container flex flex-col">
<span className="text-outline text-[10px] uppercase">service.name</span>
<span className="text-primary truncate">{record.serviceName}</span>
</div>
</div>
{/* Raw JSON block */}
<pre className="p-2.5 rounded bg-surface-container text-on-surface overflow-x-auto text-[11px] leading-relaxed border border-surface-container-high/40">
<code>
{JSON.stringify(
{
timestamp: isoTime,
severity: sevLabel,
service_name: record.serviceName,
trace_id: record.traceId,
span_id: record.spanId,
body: bodyMessage,
attributes: Object.fromEntries(
(record.attributes ?? []).map((a) => [
a.key,
formatAttributeValue(a.value),
]),
),
resource_attributes: Object.fromEntries(
(record.resourceAttributes ?? []).map((a) => [
a.key,
formatAttributeValue(a.value),
]),
),
},
null,
2,
)}
</code>
</pre>
</div>
)}
</div>
);
})
)}
</div>
{/* Log Terminal Footer Status */}
<div className="flex items-center justify-between px-4 py-2 bg-surface-container-low text-outline font-mono text-xs border-t border-surface-container">
<div className="flex items-center gap-3">
<span>
Showing {visibleRecords.length} of {allRecords.length} matched events
</span>
<span className="h-3 w-px bg-surface-container-high" />
<span>Buffer: Healthy</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
<span className="text-on-surface">Auto-scroll: LIVE STREAM</span>
</div>
</div>
</main>
</div>
);
};
+513
View File
@@ -0,0 +1,513 @@
import { useCallback, useState } from "react"
import * as Tabs from "@radix-ui/react-tabs"
import {
Activity,
AlertOctagon,
LineChart,
ScrollText,
AlertTriangle,
Info,
CheckCircle2,
ChevronDown,
ChevronUp,
TrendingUp,
Wrench,
Radar,
Globe,
GitBranch,
CloudCog,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { MetricsVisualizer } from "./MetricsVisualizer"
import TraceVisualization from "./TraceVisualization"
import { IssuesVisualizer } from "./IssuesVisualizer"
import { SetupPanel } from "./SetupPanel"
import { SearchAndWatcher } from "./SearchAndWatcher"
import { FrontendVisualizer } from "./FrontendVisualizer"
import { ViewErrorBoundary } from "./ViewErrorBoundary"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { useIssues } from "../hooks/useIssues"
import { useStatsAvailability } from "../hooks/useStats"
import { TrendsVisualizer } from "./TrendsVisualizer"
import { authFetch } from "../lib/auth"
import type { Issue, LogEntry, OtelAttribute } from "../lib/types"
import { formatAttributeValue, getServicePalette, getServiceName } from "../lib/otel-utils"
import { TelemetryFilters } from "./TelemetryFilters"
import { PatternsVisualizer } from "./PatternsVisualizer"
import { RolloutsVisualizer } from "./RolloutsVisualizer"
type SeverityMeta = {
baseLabel: string
gradient: string
ring: string
icon: LucideIcon
}
const severityStyles: Record<string, SeverityMeta> = {
ERROR: {
baseLabel: "Error",
gradient: "from-rose-500 via-red-500 to-orange-400",
ring: "ring-rose-400/30",
icon: AlertTriangle,
},
WARN: {
baseLabel: "Warning",
gradient: "from-amber-400 via-orange-400 to-rose-400",
ring: "ring-amber-300/30",
icon: AlertTriangle,
},
INFO: {
baseLabel: "Info",
gradient: "from-sky-500 via-blue-500 to-violet-500",
ring: "ring-sky-400/30",
icon: Info,
},
DEBUG: {
baseLabel: "Debug",
gradient: "from-emerald-400 via-teal-400 to-cyan-400",
ring: "ring-emerald-300/30",
icon: CheckCircle2,
},
}
const fallbackSeverity: SeverityMeta = {
baseLabel: "Log",
gradient: "from-slate-600 via-slate-500 to-slate-400",
ring: "ring-white/20",
icon: Info,
}
const getSeverityPresentation = (
severityText?: string,
severityNumber?: number,
) => {
if (severityText) {
const normalized = severityText.toUpperCase()
if (severityStyles[normalized]) {
const meta = severityStyles[normalized]
return { ...meta, display: meta.baseLabel }
}
return { ...fallbackSeverity, display: severityText }
}
if (severityNumber !== undefined) {
if (severityNumber >= 17) {
const meta = severityStyles.ERROR
return { ...meta, display: meta.baseLabel }
}
if (severityNumber >= 13) {
const meta = severityStyles.WARN
return { ...meta, display: meta.baseLabel }
}
if (severityNumber >= 9) {
const meta = severityStyles.INFO
return { ...meta, display: meta.baseLabel }
}
const meta = severityStyles.DEBUG
return { ...meta, display: meta.baseLabel }
}
return { ...fallbackSeverity, display: fallbackSeverity.baseLabel }
}
// tabItems moved inside component to access live data
export const LogEntryView = ({ log }: { log: LogEntry }) => {
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
const [expandedId, setExpandedId] = useState<string | null>(null)
const logRecords = (log.scopeLogs ?? []).flatMap((scopeLog) =>
(scopeLog.logRecords ?? []).map((record) => ({
...record,
scope: scopeLog.scope?.name,
resourceAttributes: log.resource?.attributes ?? [],
})),
)
const getRecordServiceName = (recordAttributes: OtelAttribute[]) => {
return getServiceName(log.resource?.attributes ?? [], recordAttributes)
}
const navigateToSpan = (traceId: string, spanId?: string) => {
setActiveTraceId(traceId)
if (spanId) setActiveSpanId(spanId)
setActiveTab("tab1")
}
const formatTimestamp = (timeUnixNano: string) => {
const timestamp = (parseInt(timeUnixNano ?? "0") || 0) / 1e9
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 })
}
if (logRecords.length === 0) {
return null
}
return (
<div className="flex flex-col gap-1.5">
{logRecords.map((record) => {
const severity = getSeverityPresentation(
record.severityText,
record.severityNumber,
)
const bodyMessage = record.body?.stringValue ?? "(no body)"
const recordKey = record.uid
const isExpanded = expandedId === recordKey
return (
<div
key={recordKey}
className="group relative flex flex-col overflow-hidden rounded-xl border border-white/5 bg-slate-950/40 transition-all hover:border-white/10 hover:bg-slate-900/50"
>
{/* The Main Log Row */}
<div
onClick={() => setExpandedId(isExpanded ? null : recordKey)}
className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-2.5 font-mono text-[12px]"
>
<div className={`absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b ${severity.gradient} opacity-40 transition-opacity group-hover:opacity-100`} />
{/* Timestamp */}
<span className="shrink-0 text-slate-500 whitespace-nowrap ml-1">
{formatTimestamp(record.timeUnixNano)}
</span>
{/* Service Badge */}
{(() => {
const sName = getRecordServiceName(record.attributes ?? [])
const palette = getServicePalette(sName)
return (
<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`}>
{sName}
</span>
)
})()}
{/* Severity Badge */}
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r ${severity.gradient} text-slate-950`}>
{severity.display}
</span>
{/* Log Message */}
<span className="flex-1 min-w-[300px] break-all text-slate-200">
{bodyMessage}
</span>
{/* Action Icons */}
<div className="flex items-center gap-2">
{record.traceId && (
<button
onClick={(e) => {
e.stopPropagation()
navigateToSpan(record.traceId!, record.spanId)
}}
className="group/trace flex 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"
>
<Activity className="h-2.5 w-2.5 group-hover/trace:animate-pulse" />
TRACE:{record.traceId.slice(0, 6)}
</button>
)}
{isExpanded ? <ChevronUp className="h-3 w-3 text-slate-500" /> : <ChevronDown className="h-3 w-3 text-slate-500" />}
</div>
</div>
{/* Expansion Content */}
{isExpanded && (
<div className="border-t border-white/5 bg-black/40 p-4 text-[12px]">
<div className="grid gap-6 md:grid-cols-2">
{(record.attributes ?? []).length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Attributes</p>
<div className="space-y-1">
{(record.attributes ?? []).map(attr => (
<div key={attr.key} className="flex gap-2">
<span className="text-sky-400 shrink-0">{attr.key}:</span>
<span className="text-slate-300 break-all">{formatAttributeValue(attr.value)}</span>
</div>
))}
</div>
</div>
)}
{(record.resourceAttributes ?? []).length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Resource</p>
<div className="space-y-1">
{(record.resourceAttributes ?? []).map(attr => (
<div key={attr.key} className="flex gap-2">
<span className="text-emerald-400 shrink-0">{attr.key}:</span>
<span className="text-slate-400 break-all">{formatAttributeValue(attr.value)}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-white/5 flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-4 text-[10px] text-slate-500">
<span>Scope: {record.scope || "unknown"}</span>
{record.spanId && <span>Span ID: {record.spanId}</span>}
{record.traceId && <span>Trace ID: {record.traceId}</span>}
</div>
{record.traceId && (
<button
onClick={() => navigateToSpan(record.traceId!, record.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" />
Jump to Span in Trace
</button>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
import { StructuredLogTerminal } from "./StructuredLogTerminal"
const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => {
return <StructuredLogTerminal logs={logs} />
}
export const TabsComponent = () => {
const { traces, logs, metrics, activeTab, setActiveTab } = useObservabilityData()
const issuesState = useIssues()
const [statusBusy, setStatusBusy] = useState(false)
// Trends tab only exists when the backend has ClickHouse-backed stats;
// null while the capability check is in flight.
const statsAvailable = useStatsAvailability()
// Triage action: PATCH the issue status, then refresh so counts/badges
// reflect the server state. Failures surface via the issues error banner.
const handleIssueStatusChange = useCallback(
async (issue: Issue, status: string) => {
setStatusBusy(true)
try {
const res = await authFetch(`/api/issues/${issue.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.error("Failed to update issue status:", err)
} finally {
await issuesState.refresh()
setStatusBusy(false)
}
},
[issuesState],
)
const numberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
})
const frontendCount = logs.reduce((acc, log) => {
return (
acc +
(log.scopeLogs ?? []).reduce((sAcc, sl) => {
return (
sAcc +
(sl.logRecords ?? []).filter((r) => {
const attrs = r.attributes ?? []
return (
attrs.some((a) => a.key === "source" && a.value?.stringValue === "frontend") ||
sl.scope?.name === "otel-rum" ||
attrs.some((a) => a.key === "rum.vital") ||
attrs.some((a) => a.key === "browser.url")
)
}).length
)
}, 0)
)
}, 0)
const tabItems = [
{
value: "tab0",
label: "Issues",
description: "Deduplicated errors",
Icon: AlertOctagon,
count: issuesState.issues.length
},
{
value: "tab8",
label: "Patterns",
description: "Deterministic similarity review",
Icon: GitBranch,
count: undefined as number | undefined,
},
{
value: "tab9",
label: "Rollouts",
description: "Cluster deploy correlation",
Icon: CloudCog,
count: undefined as number | undefined,
},
{
value: "tab1",
label: "Traces",
description: "Span timelines & focus",
Icon: Activity,
count: traces.length
},
{
value: "tab2",
label: "Metrics",
description: "Performance trends",
Icon: LineChart,
count: metrics.length
},
{
value: "tab3",
label: "Logs",
description: "System log streams",
Icon: ScrollText,
count: logs.length
},
{
value: "tab7",
label: "Frontend & RUM",
description: "Web Vitals & browser errors",
Icon: Globe,
count: frontendCount,
},
// Historical trends need the ClickHouse-backed stats endpoints.
...(statsAvailable
? [
{
value: "tab5",
label: "Trends",
description: "History graphs & release comparison",
Icon: TrendingUp,
count: undefined as number | undefined,
},
]
: []),
{
value: "tab6",
label: "Archive & Trap",
description: "Persistent storage search & signal trap",
Icon: Radar,
count: undefined,
},
{
value: "tab4",
label: "Setup",
description: "Instrument a new service",
Icon: Wrench,
count: undefined
},
]
return (
<div className="flex flex-col gap-6">
<TelemetryFilters />
<Tabs.Root
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col gap-4"
>
<Tabs.List
className="flex gap-8 border-b border-white/10 pb-2 mb-2 w-full overflow-x-auto"
aria-label="Telemetry explorer views"
>
{tabItems.map(({ value, label, Icon, count }) => (
<Tabs.Trigger
key={value}
value={value}
id={`tab-trigger-${value}`}
aria-label={`${label} tab`}
className="group relative flex items-center gap-2.5 pb-2 text-sm font-semibold text-slate-400 outline-none transition-colors hover:text-slate-200 data-[state=active]:text-white"
>
<div className="absolute -bottom-[2px] left-0 right-0 h-0.5 bg-sky-400 opacity-0 transition-opacity group-data-[state=active]:opacity-100" />
<Icon className="h-4 w-4 text-slate-500 group-data-[state=active]:text-sky-400" />
<span aria-hidden="true">{label}</span>
{count !== undefined && (
<span className="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-bold text-slate-300 group-data-[state=active]:bg-sky-500/20 group-data-[state=active]:text-sky-300">
{numberFormatter.format(count)}
</span>
)}
</Tabs.Trigger>
))}
</Tabs.List>
<Tabs.Content
className="outline-none py-2"
value="tab0"
>
{activeTab === "tab0" && (
<ViewErrorBoundary>
<IssuesVisualizer
issues={issuesState.issues}
loading={issuesState.loading}
error={issuesState.error}
refresh={issuesState.refresh}
onStatusChange={handleIssueStatusChange}
statusBusy={statusBusy}
/>
</ViewErrorBoundary>
)}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab1"
>
{activeTab === "tab1" && <ViewErrorBoundary><TraceVisualization traces={traces} /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content className="outline-none py-2" value="tab8">
{activeTab === "tab8" && <ViewErrorBoundary><PatternsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content className="outline-none py-2" value="tab9">
{activeTab === "tab9" && <ViewErrorBoundary><RolloutsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab2"
>
{activeTab === "tab2" && <ViewErrorBoundary><MetricsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab3"
>
{activeTab === "tab3" && <ViewErrorBoundary><LogVisualizer logs={logs} /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab7"
>
{activeTab === "tab7" && <ViewErrorBoundary><FrontendVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab4"
>
{activeTab === "tab4" && <ViewErrorBoundary><SetupPanel /></ViewErrorBoundary>}
</Tabs.Content>
{statsAvailable && (
<Tabs.Content
className="outline-none py-2"
value="tab5"
>
{activeTab === "tab5" && <ViewErrorBoundary><TrendsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
)}
<Tabs.Content
className="outline-none py-2"
value="tab6"
>
{activeTab === "tab6" && <ViewErrorBoundary><SearchAndWatcher /></ViewErrorBoundary>}
</Tabs.Content>
</Tabs.Root>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { X, Activity, AlertTriangle } from "lucide-react"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { getServicePalette } from "../lib/otel-utils"
export const TelemetryFilters = () => {
const {
selectedServices,
toggleService,
excludedAttributes,
removeExcludedValue,
clearAttributeKey,
uniqueServices,
filtersExpanded: isExpanded,
} = useObservabilityData()
const hasExcludedKeys = Object.keys(excludedAttributes).length > 0
if (!isExpanded) return null
return (
<div className="border-b border-white/10 pb-8 mb-6 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="grid gap-8 lg:grid-cols-2">
{/* Service Multi-Select */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Activity className="h-3 w-3 text-slate-500" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
Service Focus
</span>
</div>
<div className="flex flex-wrap gap-2.5">
{uniqueServices.map((service) => {
const isActive = selectedServices.has(service)
const palette = getServicePalette(service)
return (
<button
key={service}
type="button"
onClick={() => toggleService(service)}
className={`group relative inline-flex items-center gap-3 rounded-2xl border px-5 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300 ${isActive
? `border-transparent bg-gradient-to-br ${palette.gradient} text-slate-950 shadow-lg shadow-sky-500/20`
: "border-white/10 bg-slate-900/40 text-slate-400 hover:border-white/20 hover:text-white"
}`}
>
{!isActive && (
<span
className={`h-2 w-2 rounded-full bg-gradient-to-r ${palette.gradient} opacity-50 block`}
/>
)}
{service}
{isActive && (
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/10">
<X className="h-3 w-3" />
</span>
)}
</button>
)
})}
{uniqueServices.length === 0 && (
<span className="text-[11px] text-slate-600 italic font-medium px-1 underline decoration-white/5 underline-offset-4">
Listening for service signals...
</span>
)}
</div>
</div>
{/* Attribute Exclusions */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-3 w-3 text-slate-500" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
Active Noise Suppression
</span>
</div>
<div className="flex flex-wrap gap-3">
{hasExcludedKeys ? (
Object.entries(excludedAttributes).map(([key, valueSet]) => (
<div
key={key}
className="flex flex-col gap-2 rounded-2xl border border-white/5 bg-slate-900/40 p-3 transition-all hover:border-white/10"
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-bold text-sky-400 font-mono">
{key}
</span>
<button
type="button"
onClick={() => clearAttributeKey(key)}
className="text-[9px] font-black uppercase tracking-widest text-slate-600 hover:text-white transition-colors ml-4"
>
Remove All
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{Array.from(valueSet).map((value) => (
<button
key={value}
type="button"
onClick={() => removeExcludedValue(key, value)}
className="group inline-flex items-center gap-2 rounded-full border border-white/5 bg-slate-950/80 px-2.5 py-0.5 text-[10px] text-slate-300 transition-all hover:border-rose-500/50 hover:text-rose-400"
>
<span className="max-w-[10rem] truncate">{value}</span>
<X className="h-2.5 w-2.5 text-slate-600 group-hover:text-rose-500" />
</button>
))}
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center w-full py-4 border border-dashed border-white/5 rounded-3xl bg-slate-900/20">
<p className="text-[11px] text-slate-600 italic font-medium">
No active attribute exclusions.
</p>
<p className="text-[9px] text-slate-700 uppercase tracking-widest mt-1">
Click attributes in details to suppress chatty items
</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import React, { useRef, useEffect } from "react";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { useIssues } from "../hooks/useIssues";
import { currentUser, login, logout } from "../lib/auth";
export const TopHeader: React.FC = () => {
const user = currentUser();
const {
searchQuery,
setSearchQuery,
showErrorsOnly,
setShowErrorsOnly,
resetFilters,
setActiveTab,
timeWindow,
setTimeWindow,
isStreaming,
setIsStreaming,
bufferedCount,
flushBufferedStream,
minDurationMs,
setMinDurationMs,
} = useObservabilityData();
const { issues } = useIssues();
const searchInputRef = useRef<HTMLInputElement>(null);
const openIssuesCount = React.useMemo(() => {
return issues.filter((i) => !i.status || i.status === "open").length;
}, [issues]);
// ⌘K / Ctrl+K keyboard shortcut to focus search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<header className="fixed top-0 left-72 right-0 h-14 bg-surface-container-lowest/90 backdrop-blur-xl z-40 flex items-center justify-between px-6 shadow-[0_1px_8px_rgba(0,0,0,0.04)] border-b border-surface-container/60 font-body">
{/* Search and Time Range */}
<div className="flex items-center gap-3 flex-1 max-w-3xl">
{/* Global Search Bar */}
<div className="relative flex-1">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-outline text-[18px] pointer-events-none">
search
</span>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="⌘K search attributes, services, traceId, spanId, or log payload..."
className="w-full h-8 pl-8 pr-16 rounded-lg bg-surface-container-low text-on-surface font-mono text-xs placeholder:text-outline focus:outline-none focus:bg-surface-container transition-colors border border-surface-container/40"
/>
<div className="absolute right-2.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5 pointer-events-none select-none">
<kbd className="font-mono text-[10px] text-outline px-1 rounded bg-surface-container-high border border-white/5">
⌘
</kbd>
<kbd className="font-mono text-[10px] text-outline px-1 rounded bg-surface-container-high border border-white/5">
K
</kbd>
</div>
</div>
{/* Real Time Window Selector */}
<div className="flex items-center bg-surface-container-low rounded-lg p-0.5 border border-surface-container/40 select-none">
<span className="material-symbols-outlined text-outline text-[14px] ml-1 mr-0.5">
schedule
</span>
{(["15m", "1h", "6h", "24h", "all"] as const).map((w) => (
<button
key={w}
type="button"
onClick={() => setTimeWindow(w)}
className={`px-2 py-0.5 font-mono text-[11px] rounded transition-colors ${
timeWindow === w
? "bg-surface-container text-primary-container font-bold shadow-sm"
: "text-outline hover:text-on-surface"
}`}
>
{w === "all" ? "All" : w}
</button>
))}
</div>
</div>
{/* Action Buttons & Streaming Controls */}
<div className="flex items-center gap-3">
{/* Real Streaming Pause/Resume Button */}
<button
type="button"
onClick={() => {
if (isStreaming) {
setIsStreaming(false);
} else {
setIsStreaming(true);
flushBufferedStream();
}
}}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] font-semibold transition-colors border ${
isStreaming
? "bg-surface-container-low text-secondary border-surface-container hover:bg-surface-container"
: "bg-amber-500/20 text-amber-300 border-amber-500/40 animate-pulse"
}`}
title={isStreaming ? "Pause stream to freeze buffer for inspection" : "Resume live telemetry tail"}
>
<span
className={`w-2 h-2 rounded-full ${
isStreaming ? "bg-secondary animate-pulse" : "bg-amber-400"
}`}
/>
<span>
{isStreaming
? "Live Tail"
: `Paused ${bufferedCount > 0 ? `(${bufferedCount})` : ""}`}
</span>
</button>
{/* All vs Errors Toggle */}
<div className="flex rounded-lg bg-surface-container-low p-0.5 border border-surface-container/40">
<button
type="button"
onClick={() => setShowErrorsOnly(false)}
className={`px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
!showErrorsOnly
? "bg-surface-container-high text-primary font-bold shadow-sm"
: "text-outline hover:text-on-surface"
}`}
>
All
</button>
<button
type="button"
onClick={() => setShowErrorsOnly(true)}
className={`flex items-center gap-1 px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
showErrorsOnly
? "bg-error-container text-on-error-container font-bold shadow-sm"
: "text-outline hover:text-error"
}`}
>
<span className="material-symbols-outlined text-[12px]">warning</span>
Errors
</button>
</div>
{/* Slow Spans Toggle (>500ms) */}
<button
type="button"
onClick={() => setMinDurationMs(minDurationMs > 0 ? 0 : 500)}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded-lg border transition-colors ${
minDurationMs > 0
? "bg-amber-500/20 text-amber-300 border-amber-500/40 font-bold"
: "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
}`}
title="Filter to slow spans (>500ms latency)"
>
<span className="material-symbols-outlined text-[13px]">speed</span>
<span>&gt;500ms</span>
</button>
<button
type="button"
onClick={() => {
resetFilters();
setMinDurationMs(0);
}}
className="px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider text-outline hover:text-on-surface rounded-lg bg-surface-container-low hover:bg-surface-container border border-surface-container/40 transition-colors"
title="Reset active filters"
>
Reset
</button>
<div className="h-4 w-px bg-surface-container-high" />
{/* Active Issues / Incident Counter Button */}
<button
type="button"
onClick={() => setActiveTab("tab0")}
aria-label="Active issues"
className={`relative flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-xs font-bold transition-colors border ${
openIssuesCount > 0
? "bg-error-container text-on-error-container border-error/30 animate-pulse"
: "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
}`}
title="Open Issues & Anomalies"
>
<span className="material-symbols-outlined text-[16px]">notifications_active</span>
<span>{openIssuesCount}</span>
</button>
{/* User Account / Auth */}
{user ? (
<div className="flex items-center gap-2 pl-1 select-none">
<div
className="w-8 h-8 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-xs border border-primary/20 shadow-sm"
title={user.email || user.sub}
>
{(user.name || user.email || user.sub).charAt(0).toUpperCase()}
</div>
<div className="hidden xl:flex flex-col text-left">
<span className="font-body text-xs text-on-surface leading-tight font-medium truncate max-w-[120px]">
{user.name || user.email?.split("@")[0] || user.sub}
</span>
<button
type="button"
onClick={() => logout()}
className="font-mono text-[10px] text-outline hover:text-error text-left leading-none transition-colors"
>
Sign out
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => login()}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 font-mono text-xs transition-colors border border-primary/20 select-none"
>
<span className="material-symbols-outlined text-[15px]">login</span>
<span>Sign in</span>
</button>
)}
</div>
</header>
);
};
File diff suppressed because it is too large. Load diff
+323
View File
@@ -0,0 +1,323 @@
import { useMemo, useState } from "react"
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { AlertOctagon, GitCompareArrows, RefreshCw, Server } from "lucide-react"
import { useStats } from "../hooks/useStats"
import type { StatsPoint } from "../lib/types"
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
})
// Stable palette for release lines; cycles when there are many releases.
const RELEASE_COLORS = ["#38bdf8", "#f472b6", "#34d399", "#fbbf24", "#a78bfa", "#fb7185"]
const label = (bucket: string, granularity: "hour" | "day") => {
const d = new Date(bucket.replace(" ", "T") + "Z")
if (Number.isNaN(d.getTime())) return bucket
return granularity === "day"
? d.toLocaleDateString([], { month: "short", day: "numeric" })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
}
const ChartTooltip = ({ active, payload, granularity }: any) => {
if (!active || !payload?.length) return null
const p = payload[0].payload
const d = new Date(String(p.bucket).replace(" ", "T") + "Z")
return (
<div className="rounded-xl border border-white/10 bg-slate-950/95 p-3 text-xs shadow-2xl backdrop-blur-md">
<div className="mb-1 font-mono text-[10px] text-slate-400">
{d.toLocaleString([], {
month: "short",
day: "numeric",
hour: granularity === "hour" ? "2-digit" : undefined,
minute: granularity === "hour" ? "2-digit" : undefined,
hour12: false,
})}
</div>
{payload.map((entry: any) => (
<div key={entry.name} className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5 text-slate-300">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: entry.color }} />
{entry.name}
</span>
<span className="font-mono font-bold text-white">{entry.value}</span>
</div>
))}
</div>
)
}
const rangeOptions = [24, 72, 168, 720] // 1d, 3d, 7d, 30d
export const TrendsVisualizer = () => {
const [hours, setHours] = useState(72)
const [service, setService] = useState("")
const { errorPoints, granularity, releaseSeries, services, loading, error, refresh } = useStats({
hours,
service,
})
const errorData = useMemo(
() =>
errorPoints.map((p: StatsPoint) => ({
bucket: p.bucket,
total: p.total,
errors: p.errors,
})),
[errorPoints],
)
const releases = useMemo(() => Object.keys(releaseSeries).sort(), [releaseSeries])
const releaseData = useMemo(() => {
// Recharts wants rows, not per-series arrays: bucket -> {release: errors}.
const byBucket: Record<string, Record<string, number | string>> = {}
releases.forEach((rel) => {
releaseSeries[rel].forEach((p) => {
byBucket[p.bucket] = byBucket[p.bucket] || { bucket: p.bucket }
byBucket[p.bucket][rel] = p.errors
})
})
return Object.values(byBucket).sort((a, b) =>
String(a.bucket).localeCompare(String(b.bucket)),
)
}, [releaseSeries, releases])
const totals = useMemo(() => {
let total = 0
let errors = 0
errorPoints.forEach((p) => {
total += p.total
errors += p.errors
})
return { total, errors }
}, [errorPoints])
if (loading && errorPoints.length === 0) {
return (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-3 text-slate-400">
<RefreshCw className="h-6 w-6 animate-spin opacity-40" />
<p className="text-sm">Loading trends…</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-3 border-b border-white/5 bg-slate-950/80 py-2 backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
<GitCompareArrows className="h-3 w-3 text-sky-400" />
Historical Trends
</span>
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
{rangeOptions.map((h) => (
<button
key={h}
onClick={() => setHours(h)}
className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
hours === h
? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
: "text-slate-500 hover:text-slate-300"
}`}
>
{h < 24 ? `${h}h` : `${h / 24}d`}
</button>
))}
</div>
{services.length > 0 && (
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="rounded-lg border border-white/10 bg-slate-900/60 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-300 outline-none"
aria-label="Filter by service"
>
<option value="">All services</option>
{services.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-slate-500">
{compactFormatter.format(totals.total)} entries ·{" "}
<span className={totals.errors > 0 ? "text-rose-400" : "text-emerald-400"}>
{compactFormatter.format(totals.errors)} errors
</span>
</span>
<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 transition-all hover:border-white/20 hover:text-white"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-300">
<AlertOctagon className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Error trend */}
<section className="rounded-2xl border border-white/5 bg-slate-950/40 p-4">
<header className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-slate-400">
<AlertOctagon className="h-3.5 w-3.5 text-rose-400" />
Entries &amp; errors over time
</h3>
<span className="text-[10px] uppercase tracking-wider text-slate-600">
per {granularity}
</span>
</header>
{errorData.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-500">
No data in this range yet.
</p>
) : (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={errorData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="100%">
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.35} />
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
</linearGradient>
<linearGradient id="trendErrFill" x1="0" y1="0" x2="0" y2="100%">
<stop offset="5%" stopColor="#fb7185" stopOpacity={0.45} />
<stop offset="95%" stopColor="#fb7185" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(255,255,255,0.05)" />
<XAxis
dataKey="bucket"
tickFormatter={(b) => label(b, granularity)}
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
minTickGap={40}
/>
<YAxis
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
width={36}
tickFormatter={(v) => compactFormatter.format(v)}
/>
<Tooltip content={<ChartTooltip granularity={granularity} />} />
<Legend
wrapperStyle={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.1em" }}
/>
<Area
type="monotone"
dataKey="total"
name="Total"
stroke="#38bdf8"
strokeWidth={2}
fill="url(#trendFill)"
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="errors"
name="Errors"
stroke="#fb7185"
strokeWidth={2}
fill="url(#trendErrFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</section>
{/* Release comparison */}
<section className="rounded-2xl border border-white/5 bg-slate-950/40 p-4">
<header className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-slate-400">
<GitCompareArrows className="h-3.5 w-3.5 text-sky-400" />
Errors by release
</h3>
<span className="text-[10px] uppercase tracking-wider text-slate-600">
{releases.length} release{releases.length === 1 ? "" : "s"} · overlay
</span>
</header>
{releaseData.length === 0 || releases.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-500">
No release-tagged telemetry in this range. Have services send{" "}
<code className="rounded bg-white/5 px-1 font-mono text-[10px]">service.release</code>{" "}
to compare deploys here.
</p>
) : (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={releaseData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(255,255,255,0.05)" />
<XAxis
dataKey="bucket"
tickFormatter={(b) => label(b, "hour")}
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
minTickGap={40}
/>
<YAxis
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
width={36}
tickFormatter={(v) => compactFormatter.format(v)}
/>
<Tooltip content={<ChartTooltip granularity="hour" />} />
<Legend
wrapperStyle={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.1em" }}
/>
{releases.map((rel, i) => (
<Line
key={rel}
type="monotone"
dataKey={rel}
stroke={RELEASE_COLORS[i % RELEASE_COLORS.length]}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
<p className="flex items-center justify-center gap-1.5 pb-2 text-center text-[10px] text-slate-600">
<Server className="h-3 w-3" />
Served from the ClickHouse hourly rollup ({service || "all services"}, last {hours}h)
</p>
</div>
)
}
export default TrendsVisualizer
@@ -0,0 +1,62 @@
import { Component, type ErrorInfo, type ReactNode } from "react"
import { AlertTriangle, RotateCcw } from "lucide-react"
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
// Keeps a rendering bug in one telemetry view from blanking the whole app.
export class ViewErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("View crashed:", error, info.componentStack)
const rum = (window as Window & {
OtelRUM?: {
last?: {
captureException: (error: Error, extra?: Record<string, unknown>) => void
}
}
}).OtelRUM?.last
rum?.captureException(error, {
"exception.type": "ReactErrorBoundary",
"react.component_stack": info.componentStack,
})
}
render() {
if (this.state.error) {
return (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-3xl border border-rose-500/20 bg-rose-500/5 p-12 text-center">
<AlertTriangle className="h-10 w-10 text-rose-400" />
<div>
<p className="text-lg font-semibold text-white">
This view hit an unexpected error
</p>
<p className="mt-1 max-w-md truncate font-mono text-xs text-rose-300/80">
{this.state.error.message}
</p>
</div>
<button
type="button"
onClick={() => this.setState({ error: null })}
className="inline-flex items-center gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-rose-300 transition-all hover:bg-rose-500/20"
>
<RotateCcw className="h-3 w-3" />
Retry
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,385 @@
import { useMemo, useState } from "react";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { isSpanError } from "../lib/otel-utils";
export const WaveformHistogram = () => {
const {
logs,
traces,
unfocusedLogs,
unfocusedTraces,
selectedTimeRange,
setSelectedTimeRange,
setActiveTab,
} = useObservabilityData();
// Use unfocused data so that waveform buckets remain stable across the window
const sourceLogs = unfocusedLogs || logs;
const sourceTraces = unfocusedTraces || traces;
const { buckets, hasErrorSurge, surgePercent, timeLabels, totalCount } = useMemo(() => {
const numBuckets = 24;
const bucketStats = Array.from({ length: numBuckets }, () => ({
error: 0,
warn: 0,
info: 0,
total: 0,
traceCount: 0,
logCount: 0,
}));
// Collect timestamps and severity levels from real telemetry items
const items: Array<{ time: number; level: "error" | "warn" | "info"; isTrace?: boolean }> = [];
sourceLogs.forEach((log) => {
(log.scopeLogs ?? []).forEach((sl) => {
(sl.logRecords ?? []).forEach((r) => {
const t = Number(BigInt(r.timeUnixNano || "0") / 1_000_000n);
if (t > 0) {
const sev = r.severityNumber ?? 0;
const level = sev >= 17 ? "error" : sev >= 13 ? "warn" : "info";
items.push({ time: t, level, isTrace: false });
}
});
});
});
sourceTraces.forEach((trace) => {
(trace.resourceSpans ?? []).forEach((rs) => {
(rs.scopeSpans ?? []).forEach((ss) => {
(ss.spans ?? []).forEach((s) => {
const t = Number(BigInt(s.startTimeUnixNano || "0") / 1_000_000n);
if (t > 0) {
const hasErr = isSpanError(s);
items.push({ time: t, level: hasErr ? "error" : "info", isTrace: true });
}
});
});
});
});
if (items.length === 0) {
return {
buckets: [],
hasErrorSurge: false,
surgePercent: 0,
timeLabels: [],
totalCount: 0,
};
}
const minTime = Math.min(...items.map((it) => it.time));
const maxTime = Math.max(...items.map((it) => it.time));
const range = Math.max(maxTime - minTime, 1000);
items.forEach((it) => {
const idx = Math.min(numBuckets - 1, Math.floor(((it.time - minTime) / range) * numBuckets));
bucketStats[idx][it.level] += 1;
bucketStats[idx].total += 1;
if (it.isTrace) {
bucketStats[idx].traceCount += 1;
} else {
bucketStats[idx].logCount += 1;
}
});
// Check for surge in real errors
let maxErrors = 0;
let baselineErrors = 0;
bucketStats.forEach((b, idx) => {
if (b.error > maxErrors) maxErrors = b.error;
if (idx < numBuckets / 2) baselineErrors += b.error;
});
const avgBaseline = baselineErrors / (numBuckets / 2) || 1;
const computedSurge = Math.round(((maxErrors - avgBaseline) / avgBaseline) * 100);
const timeLabels = [
new Date(minTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
new Date(minTime + range * 0.33).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
new Date(minTime + range * 0.66).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
new Date(maxTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
];
// Normalize heights to percentage
const maxBucketTotal = Math.max(...bucketStats.map((b) => b.total), 1);
const normalized = bucketStats.map((b, idx) => {
const scale = (b.total / maxBucketTotal) * 100;
const errorPct = b.total > 0 ? (b.error / b.total) * scale : 0;
const warnPct = b.total > 0 ? (b.warn / b.total) * scale : 0;
const infoPct = b.total > 0 ? (b.info / b.total) * scale : 0;
const startTime = minTime + Math.floor((idx * range) / numBuckets);
const endTime = minTime + Math.floor(((idx + 1) * range) / numBuckets);
const timeLabel = `${new Date(startTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })} – ${new Date(endTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
return {
idx,
error: errorPct,
warn: warnPct,
info: infoPct,
rawError: b.error,
rawWarn: b.warn,
rawInfo: b.info,
total: b.total,
traceCount: b.traceCount,
logCount: b.logCount,
startTime,
endTime,
timeLabel,
};
});
return {
buckets: normalized,
hasErrorSurge: maxErrors >= 5 && computedSurge > 50,
surgePercent: computedSurge,
timeLabels,
totalCount: items.length,
};
}, [sourceLogs, sourceTraces]);
const [isCollapsed, setIsCollapsed] = useState(false);
// Check which bucket matches the active selectedTimeRange
const activeBucket = useMemo(() => {
if (!selectedTimeRange || buckets.length === 0) return null;
return (
buckets.find(
(b) =>
selectedTimeRange.startMs <= b.endTime && selectedTimeRange.endMs >= b.startTime,
) || null
);
}, [selectedTimeRange, buckets]);
const handleBucketClick = (b: (typeof buckets)[0]) => {
if (activeBucket && activeBucket.idx === b.idx) {
// Toggle off if clicking the already selected bucket
setSelectedTimeRange(null);
} else {
setSelectedTimeRange({
startMs: b.startTime,
endMs: b.endTime,
label: b.timeLabel,
});
}
};
if (buckets.length === 0) {
return (
<div className="relative bg-surface-container-lowest p-3.5 rounded-xl shadow-md mb-4 border border-surface-container/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary-container text-[18px]">
monitoring
</span>
<h2 className="font-headline font-semibold text-xs text-on-surface">
Telemetry Volume &amp; Error Waveform
</h2>
</div>
<span className="font-mono text-xs text-outline">Listening for OTLP traffic...</span>
</div>
</div>
);
}
return (
<div className="relative bg-surface-container-lowest p-3.5 rounded-xl shadow-md mb-4 overflow-hidden border border-surface-container/50 font-body">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex items-center gap-1.5 hover:text-white transition-colors"
>
<span className="material-symbols-outlined text-primary-container text-[18px]">
monitoring
</span>
<h2 className="font-headline font-semibold text-xs uppercase tracking-wider text-on-surface leading-none">
Telemetry Volume Waveform
</h2>
<span className="material-symbols-outlined text-outline text-[16px]">
{isCollapsed ? "expand_more" : "expand_less"}
</span>
</button>
<span className="font-mono text-xs text-outline">
{totalCount} events in current buffer
</span>
{hasErrorSurge && (
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-bold animate-pulse flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-error" />
SURGE +{surgePercent}%
</span>
)}
{selectedTimeRange && (
<span className="px-2 py-0.5 rounded-full bg-sky-500/20 text-sky-300 font-mono text-[10px] font-bold border border-sky-400/30 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-sky-400 animate-ping" />
Time Focus Active
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedTimeRange(null);
}}
className="ml-1 hover:text-white"
title="Clear time focus"
>
✕
</button>
</span>
)}
</div>
{/* Legend */}
<div className="flex items-center gap-4 font-mono text-xs select-none">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-error"></span>
<span className="text-on-surface">ERROR</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-primary-fixed-dim"></span>
<span className="text-on-surface">WARN</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm bg-secondary"></span>
<span className="text-on-surface">INFO</span>
</div>
</div>
</div>
{!isCollapsed && (
<>
{/* Stacked SVG Histogram with Real Traffic */}
<div className="relative w-full h-24 flex items-end">
<div className="w-full h-full flex items-end justify-between gap-1 pt-3">
{buckets.map((b) => {
const isSurgeBar = hasErrorSurge && b.error > 40;
const isSelected = activeBucket?.idx === b.idx;
const hasAnySelected = activeBucket !== null;
return (
<div
key={b.idx}
onClick={() => handleBucketClick(b)}
className={`flex-1 flex flex-col justify-end h-full gap-0.5 group cursor-pointer relative transition-all rounded-sm ${
isSelected
? "ring-2 ring-sky-400 bg-sky-400/25 scale-y-105 z-20 shadow-md shadow-sky-500/20"
: hasAnySelected
? "opacity-40 hover:opacity-100 hover:brightness-125"
: "hover:brightness-125"
}`}
title={`${b.timeLabel}\nTraces: ${b.traceCount} • Logs: ${b.logCount}\nErrors: ${b.rawError}, Warns: ${b.rawWarn}, Info: ${b.rawInfo}\nClick to ${isSelected ? "clear focus" : "focus and show events for this period"}`}
>
{/* Active Selection Focus Pin Indicator */}
{isSelected && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 flex flex-col items-center pointer-events-none z-20 whitespace-nowrap">
<span className="px-1.5 py-0.5 bg-sky-500 text-slate-950 font-mono text-[9px] font-black rounded shadow">
FOCUS
</span>
<span className="w-0.5 h-1 bg-sky-400"></span>
</div>
)}
{/* Incident Flag Marker on Real Surge */}
{!isSelected && isSurgeBar && b.idx === 15 && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 flex flex-col items-center pointer-events-none z-10 whitespace-nowrap animate-bounce">
<span className="px-1.5 py-0.5 bg-error-container text-on-error-container font-mono text-[10px] rounded shadow font-semibold flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-error animate-ping"></span>
SURGE +{surgePercent}%
</span>
<span className="w-0.5 h-2 bg-error"></span>
</div>
)}
{/* Error segment */}
{b.error > 0 && (
<div
className="w-full bg-error rounded-t-sm transition-all group-hover:brightness-125"
style={{ height: `${b.error}%` }}
/>
)}
{/* Warn segment */}
{b.warn > 0 && (
<div
className="w-full bg-primary-fixed-dim transition-all group-hover:brightness-125"
style={{ height: `${b.warn}%` }}
/>
)}
{/* Info segment */}
{b.info > 0 && (
<div
className="w-full bg-secondary/80 rounded-b-sm transition-all group-hover:brightness-125"
style={{ height: `${b.info}%` }}
/>
)}
</div>
);
})}
</div>
</div>
{/* Real Time Scale Axes */}
<div className="flex justify-between items-center text-outline font-mono text-xs pt-2 select-none border-t border-surface-container/40 mt-1">
<span>{timeLabels[0]}</span>
<span>{timeLabels[1]}</span>
<span className={hasErrorSurge ? "text-error font-semibold" : ""}>
{timeLabels[2]} {hasErrorSurge ? "(SURGE)" : ""}
</span>
<span>{timeLabels[3]}</span>
</div>
{/* Time Focus Bar with quick tab navigation and clear button */}
{activeBucket && (
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-3 rounded-xl bg-sky-500/10 border border-sky-500/30 px-3.5 py-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex items-center gap-2.5 min-w-0">
<span className="flex h-2 w-2 rounded-full bg-sky-400 animate-ping" />
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-xs text-white">
Focused Time Period:
</span>
<span className="font-mono text-xs text-sky-300 font-bold">
{activeBucket.timeLabel}
</span>
<span className="text-[11px] text-slate-400 font-mono">
({activeBucket.traceCount} trace events • {activeBucket.logCount} log records
{activeBucket.rawError > 0 ? ` • ${activeBucket.rawError} errors` : ""})
</span>
</div>
</div>
<div className="flex items-center gap-2 font-mono text-xs">
<button
type="button"
onClick={() => setActiveTab("tab1")}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 font-bold transition-all border border-sky-400/30"
>
<span className="material-symbols-outlined text-[14px]">timeline</span>
Show Traces ({activeBucket.traceCount})
</button>
<button
type="button"
onClick={() => setActiveTab("tab3")}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 font-bold transition-all border border-sky-400/30"
>
<span className="material-symbols-outlined text-[14px]">receipt_long</span>
Show Logs ({activeBucket.logCount})
</button>
<button
type="button"
onClick={() => setSelectedTimeRange(null)}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-300 transition-colors"
title="Clear time focus"
>
<span className="material-symbols-outlined text-[14px]">close</span>
Clear Focus
</button>
</div>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,799 @@
"use client"
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
import { useLocation } from "wouter"
import { tabForPath, pathForTab } from "../lib/routes"
import type {
MetricData,
OtelHistoryMessage,
OtelMessage,
LogEntry,
TraceData,
ResourceSpan,
ScopeSpan,
Span,
} from "../lib/types"
import { ObservabilityContext, type TimeRangeFilter } from "./ObservabilityContextValue"
import { getServiceName, formatAttributeValue, isSpanError } from "../lib/otel-utils"
import { partitionMessages, mergeLogsByTraceId } from "../lib/ingest"
import { authFetch, tokens } from "../lib/auth"
interface ObservabilityProviderProps {
children: ReactNode
}
export function ObservabilityProvider({
children,
}: ObservabilityProviderProps) {
const [logsByTraceId, setLogsByTraceId] = useState<
Record<string, LogEntry[]>
>({})
const [traces, setTraces] = useState<TraceData[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [metrics, setMetrics] = useState<MetricData[]>([])
const [isConnected, setIsConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number>(null)
// --- Real Streaming / Pause Buffer State ---
const [isStreaming, setIsStreaming] = useState(true)
const isStreamingRef = useRef(true)
const bufferedMessagesRef = useRef<OtelMessage[]>([])
const [bufferedCount, setBufferedCount] = useState(0)
// --- Issues live update counter ---
const [issuesVersion, setIssuesVersion] = useState(0)
// --- Latency filter (milliseconds) ---
const [minDurationMs, setMinDurationMs] = useState<number>(0)
// --- Persistence Keys ---
const SERVICES_STORAGE_KEY = "otel_selected_services"
const ATTRIBUTES_STORAGE_KEY = "otel_excluded_attributes"
const SHOW_ERRORS_STORAGE_KEY = "otel_show_errors_only"
const SEARCH_QUERY_STORAGE_KEY = "otel_search_query"
const TIME_WINDOW_STORAGE_KEY = "otel_time_window"
// --- Time Window Filter ---
const [timeWindow, setTimeWindow] = useState<"15m" | "1h" | "6h" | "24h" | "all">(() => {
if (typeof window === "undefined") return "15m"
return (localStorage.getItem(TIME_WINDOW_STORAGE_KEY) as any) || "15m"
})
// --- Selected Time Period Filter (e.g. from clicking waveform) ---
const [selectedTimeRange, setSelectedTimeRange] = useState<TimeRangeFilter | null>(null)
// --- Filtering State ---
const defaultExcludedAttributes = useCallback((): Record<string, Set<string>> => ({
"user_agent.original": new Set([
"kube-probe/1.33",
"kube-probe/1.31",
"Uptime-Kuma/1.23.11",
]),
"http.target": new Set(["/healthz", "/readyz", "/livez"]),
"http.host": new Set(["git.k6n.net"]),
"http.user_agent": new Set(["Uptime-Kuma/1.23.11"]),
}), [])
const [selectedServices, setSelectedServices] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set()
const saved = localStorage.getItem(SERVICES_STORAGE_KEY)
return saved ? new Set(JSON.parse(saved)) : new Set()
})
const [excludedAttributes, setExcludedAttributes] = useState<Record<string, Set<string>>>(() => {
if (typeof window === "undefined") return defaultExcludedAttributes()
const saved = localStorage.getItem(ATTRIBUTES_STORAGE_KEY)
if (!saved) return defaultExcludedAttributes()
try {
const parsed = JSON.parse(saved)
const restored: Record<string, Set<string>> = {}
Object.entries(parsed).forEach(([key, values]) => {
restored[key] = new Set(values as string[])
})
return restored
} catch (e) {
console.error("Failed to restore excluded attributes", e)
return defaultExcludedAttributes()
}
})
const [showErrorsOnly, setShowErrorsOnly] = useState<boolean>(() => {
if (typeof window === "undefined") return false
const saved = localStorage.getItem(SHOW_ERRORS_STORAGE_KEY)
return saved === "true"
})
const [searchQuery, setSearchQuery] = useState<string>(() => {
if (typeof window === "undefined") return ""
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) || ""
})
// --- Persistence Effect ---
useEffect(() => {
if (typeof window === "undefined") return
localStorage.setItem(SERVICES_STORAGE_KEY, JSON.stringify(Array.from(selectedServices)))
const serializeable: Record<string, string[]> = {}
Object.entries(excludedAttributes).forEach(([key, values]) => {
serializeable[key] = Array.from(values)
})
localStorage.setItem(ATTRIBUTES_STORAGE_KEY, JSON.stringify(serializeable))
localStorage.setItem(SHOW_ERRORS_STORAGE_KEY, showErrorsOnly.toString())
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, searchQuery)
localStorage.setItem(TIME_WINDOW_STORAGE_KEY, timeWindow)
}, [selectedServices, excludedAttributes, showErrorsOnly, searchQuery, timeWindow])
// --- Selection State ---
const [activeTraceId, setActiveTraceId] = useState<string | null>(null)
const [activeSpanId, setActiveSpanId] = useState<string | null>(null)
// Tab state lives in the URL (wouter): /issues, /traces, ... so deep
// links survive the OIDC login redirect and views are shareable. The
// state mirror below keeps every existing setActiveTab call site working;
// it follows the URL on navigation and pushes the URL on programmatic
// setActiveTab calls.
const [location, navigate] = useLocation()
const [activeTab, setActiveTab] = useState<string>(
() => tabForPath(window.location.pathname) ?? "tab0",
)
// External navigation (back/forward, manual URL edit, login redirect):
// adopt the tab encoded in the new location.
useEffect(() => {
const tab = tabForPath(location)
if (tab && tab !== activeTab) setActiveTab(tab)
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is intentionally read stale here; see comment above
}, [location])
const setActiveTabWithRoute = useCallback(
(tab: string) => {
setActiveTab(tab)
const path = pathForTab(tab)
if (path && path !== window.location.pathname) navigate(path)
},
[navigate],
)
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false)
// Trace ids we already have spans for, so selecting a trace that fell
// outside the initial history window (or arrived before this session
// connected) triggers an on-demand GET /api/traces/{traceId} instead of
// silently showing nothing.
const [knownTraceIds, setKnownTraceIds] = useState<Set<string>>(new Set())
const toggleService = useCallback((service: string) => {
setSelectedServices(prev => {
const next = new Set(prev)
if (next.has(service)) {
next.delete(service)
} else {
next.add(service)
}
return next
})
}, [])
const toggleExcludedValue = useCallback((key: string, value: string) => {
setExcludedAttributes((prev) => {
const currentSet = prev[key] ?? new Set<string>()
const nextSet = new Set(currentSet)
if (nextSet.has(value)) {
nextSet.delete(value)
} else {
nextSet.add(value)
}
const next = { ...prev }
if (nextSet.size === 0) {
delete next[key]
} else {
next[key] = nextSet
}
return next
})
}, [])
const removeExcludedValue = useCallback((key: string, value: string) => {
setExcludedAttributes((prev) => {
const current = prev[key]
if (!current || !current.has(value)) {
return prev
}
const nextSet = new Set(current)
nextSet.delete(value)
const next = { ...prev }
if (nextSet.size === 0) {
delete next[key]
} else {
next[key] = nextSet
}
return next
})
}, [])
const clearAttributeKey = useCallback((key: string) => {
setExcludedAttributes((prev) => {
if (!prev[key]) {
return prev
}
const next = { ...prev }
delete next[key]
return next
})
}, [])
const resetFilters = useCallback(() => {
setSelectedServices(new Set())
setExcludedAttributes(defaultExcludedAttributes())
setSearchQuery("")
setSelectedTimeRange(null)
}, [defaultExcludedAttributes])
// Flush buffered WebSocket stream entries when resuming
const flushBufferedStream = useCallback(() => {
const queued = bufferedMessagesRef.current
bufferedMessagesRef.current = []
setBufferedCount(0)
if (queued.length > 0) {
const partitioned = partitionMessages(queued)
if (partitioned.traces.length) {
setTraces((prev) => [...prev, ...partitioned.traces])
}
if (partitioned.metrics.length) {
setMetrics((prev) => [...prev, ...partitioned.metrics])
}
if (partitioned.logs.length) {
setLogs((prev) => [...prev, ...partitioned.logs])
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
}
if (partitioned.traceIds.size) {
setKnownTraceIds((prev) => {
const next = new Set(prev)
partitioned.traceIds.forEach((id) => next.add(id))
return next
})
}
}
}, [])
useEffect(() => {
isStreamingRef.current = isStreaming
if (isStreaming && bufferedMessagesRef.current.length > 0) {
flushBufferedStream()
}
}, [isStreaming, flushBufferedStream])
// Compute cutoff timestamp in nanoseconds for timeWindow
const cutoffNano = useMemo(() => {
if (timeWindow === "all") return 0n
const now = Date.now()
const windowMs = {
"15m": 15 * 60 * 1000,
"1h": 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"all": 0,
}[timeWindow]
return BigInt(now - windowMs) * 1_000_000n
}, [timeWindow])
// Discover all unique services across all signals
const uniqueServices = useMemo(() => {
const services = new Set<string>()
// Check logs
logs.forEach(log => {
services.add(getServiceName(log.resource?.attributes ?? []))
;(log.scopeLogs ?? []).forEach(sl => (sl.logRecords ?? []).forEach(record => {
services.add(getServiceName(log.resource?.attributes ?? [], record.attributes ?? []))
}))
})
// Check traces
traces.forEach(trace => {
trace.resourceSpans?.forEach(resourceSpan => {
services.add(getServiceName(resourceSpan.resource?.attributes ?? []))
resourceSpan.scopeSpans?.forEach(ss => {
ss.spans?.forEach(s => {
services.add(getServiceName(resourceSpan.resource?.attributes ?? [], s.attributes ?? []))
})
})
})
})
// Check metrics
metrics.forEach(metric => {
metric.resourceMetrics?.forEach(rm => {
services.add(getServiceName(rm.resource?.attributes ?? []))
})
})
return Array.from(services).sort().filter(s => s !== "unknown-service")
}, [logs, traces, metrics])
// --- Base Filtered Data (Global Filters: Service, TimeWindow, Search, Excluded Attributes) ---
const unfocusedLogs = useMemo(() => {
return logs.map(log => {
const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => {
const filteredRecords = (sl.logRecords ?? []).filter(record => {
const serviceName = getServiceName(log.resource?.attributes ?? [], record.attributes ?? [])
// Filter by service
if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return false
// Filter by time window
if (cutoffNano > 0n) {
const tNano = BigInt(record.timeUnixNano || "0")
if (tNano > 0n && tNano < cutoffNano) return false
}
// Filter by attributes
const recordAttrs = Object.fromEntries((record.attributes ?? []).map(a => [a.key, formatAttributeValue(a.value)]))
const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => {
const val = recordAttrs[key]
return val && values.has(val)
})
if (isExcluded) return false
if (showErrorsOnly) {
return (record.severityNumber ?? 0) >= 13 // 13 is Warn, 17 is Error
}
if (searchQuery) {
const query = searchQuery.toLowerCase()
const matchInBody = record.body?.stringValue?.toLowerCase()?.includes(query) ?? false
const matchInAttrs = record.attributes?.some(a =>
a.key.toLowerCase()?.includes(query) ||
formatAttributeValue(a.value).toLowerCase()?.includes(query)
) ?? false
const matchInServiceName = serviceName.toLowerCase()?.includes(query) ?? false
const matchInTrace = (record.traceId || "").toLowerCase().includes(query)
if (!matchInBody && !matchInAttrs && !matchInServiceName && !matchInTrace) return false
}
return true
})
if (filteredRecords.length === 0) return null
return { ...sl, logRecords: filteredRecords }
}).filter(Boolean)
if (filteredScopeLogs.length === 0) return null
return { ...log, scopeLogs: filteredScopeLogs as any }
}).filter(Boolean) as LogEntry[]
}, [logs, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano])
const unfocusedTraces = useMemo(() => {
return traces.map(trace => {
const filteredResourceSpans = (trace.resourceSpans?.map(rs => {
const serviceName = getServiceName(rs.resource?.attributes ?? [])
// Filter by service
if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null
const filteredScopeSpans = (rs.scopeSpans?.map(ss => {
const filteredSpans = ss.spans?.filter(span => {
// Filter by time window
if (cutoffNano > 0n) {
const tNano = BigInt(span.startTimeUnixNano || "0")
if (tNano > 0n && tNano < cutoffNano) return false
}
// Filter by latency threshold (minDurationMs)
if (minDurationMs > 0) {
const start = Number(span.startTimeUnixNano || "0")
const end = Number(span.endTimeUnixNano || span.startTimeUnixNano || "0")
const durMs = (end - start) / 1_000_000
if (durMs < minDurationMs) return false
}
// 1. Excluded attributes
const spanAttrs = Object.fromEntries(span.attributes?.map(a => [a.key, formatAttributeValue(a.value)]) ?? [])
const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => {
const val = spanAttrs[key]
return val && values.has(val)
})
if (isExcluded) return false
// 2. Search query
if (searchQuery) {
const query = searchQuery.toLowerCase()
const matchInName = (span.name || "").toLowerCase()?.includes(query) ?? false
const matchInTrace = (span.traceId || "").toLowerCase().includes(query)
const matchInAttrs = span.attributes?.some(a =>
a.key.toLowerCase()?.includes(query) ||
formatAttributeValue(a.value).toLowerCase()?.includes(query)
) ?? false
if (!matchInName && !matchInAttrs && !matchInTrace) return false
}
return true
})
if (!filteredSpans || filteredSpans.length === 0) return null
return { ...ss, spans: filteredSpans }
}).filter(Boolean) as ScopeSpan[]) || []
if (!filteredScopeSpans || filteredScopeSpans.length === 0) return null
return { ...rs, scopeSpans: filteredScopeSpans }
}).filter(Boolean) as ResourceSpan[]) || []
if (!filteredResourceSpans || filteredResourceSpans.length === 0) return null
if (showErrorsOnly) {
const hasError = filteredResourceSpans?.some((rs: ResourceSpan) =>
rs?.scopeSpans?.some((ss: ScopeSpan) =>
ss?.spans?.some((span: Span) => isSpanError(span))
)
)
if (!hasError) return null
}
return { ...trace, resourceSpans: filteredResourceSpans }
}).filter(Boolean) as TraceData[]
}, [traces, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano, minDurationMs])
// --- Selected Time Period Filtering (e.g. from waveform section click) ---
const filteredLogs = useMemo(() => {
if (!selectedTimeRange) return unfocusedLogs
const { startMs, endMs } = selectedTimeRange
return unfocusedLogs.map(log => {
const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => {
const filteredRecords = (sl.logRecords ?? []).filter(record => {
const tMs = Number(BigInt(record.timeUnixNano || "0") / 1_000_000n)
return tMs >= startMs && tMs <= endMs
})
if (filteredRecords.length === 0) return null
return { ...sl, logRecords: filteredRecords }
}).filter(Boolean)
if (filteredScopeLogs.length === 0) return null
return { ...log, scopeLogs: filteredScopeLogs as any }
}).filter(Boolean) as LogEntry[]
}, [unfocusedLogs, selectedTimeRange])
const filteredTraces = useMemo(() => {
if (!selectedTimeRange) return unfocusedTraces
const { startMs, endMs } = selectedTimeRange
return unfocusedTraces.filter(trace => {
return (trace.resourceSpans ?? []).some(rs =>
(rs.scopeSpans ?? []).some(ss =>
(ss.spans ?? []).some(span => {
const spanStartMs = Number(BigInt(span.startTimeUnixNano || "0") / 1_000_000n)
const spanEndMs = Number(BigInt(span.endTimeUnixNano || span.startTimeUnixNano || "0") / 1_000_000n)
return spanStartMs <= endMs && spanEndMs >= startMs
})
)
)
})
}, [unfocusedTraces, selectedTimeRange])
const logCountBySpanId = useMemo(() => {
const counts: Record<string, number> = {}
filteredLogs.forEach(log => {
;(log.scopeLogs ?? []).forEach(sl => {
;(sl.logRecords ?? []).forEach(record => {
if (record.spanId) {
counts[record.spanId] = (counts[record.spanId] || 0) + 1
}
})
})
})
return counts
}, [filteredLogs])
const filteredMetrics = useMemo(() => {
return metrics.map(data => {
const filteredResourceMetrics = data.resourceMetrics?.map(rm => {
const serviceName = rm.resource?.attributes?.find(attr => attr.key === "service.name")?.value.stringValue || "unknown-service"
// Filter by service
if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null
const filteredScopeMetrics = rm.scopeMetrics?.map(sm => {
const filteredMetricsList = sm.metrics?.filter(_m => {
return true
})
if (filteredMetricsList?.length === 0) return null
return { ...sm, metrics: filteredMetricsList }
}).filter(Boolean)
if (filteredScopeMetrics?.length === 0) return null
return { ...rm, scopeMetrics: filteredScopeMetrics }
}).filter(Boolean)
if (filteredResourceMetrics?.length === 0) return null
return { ...data, resourceMetrics: filteredResourceMetrics }
}).filter(Boolean) as MetricData[]
}, [metrics, selectedServices])
// --- Real Service Health & Offender Metrics ---
const serviceHealth = useMemo(() => {
const health: Record<string, {
name: string;
totalSignals: number;
errorCount: number;
spansCount: number;
logsCount: number;
hasErrors: boolean;
}> = {}
const getOrInit = (srv: string) => {
if (!health[srv]) {
health[srv] = {
name: srv,
totalSignals: 0,
errorCount: 0,
spansCount: 0,
logsCount: 0,
hasErrors: false,
}
}
return health[srv]
}
filteredLogs.forEach((log) => {
(log.scopeLogs ?? []).forEach((sl) => {
(sl.logRecords ?? []).forEach((r) => {
const sName = getServiceName(log.resource?.attributes ?? [], r.attributes ?? [])
if (sName && sName !== "unknown-service") {
const h = getOrInit(sName)
h.totalSignals++
h.logsCount++
if ((r.severityNumber ?? 0) >= 17) {
h.errorCount++
h.hasErrors = true
}
}
})
})
})
filteredTraces.forEach((trace) => {
trace.resourceSpans?.forEach((rs) => {
rs.scopeSpans?.forEach((ss) => {
ss.spans?.forEach((s) => {
const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? [])
if (sName && sName !== "unknown-service") {
const h = getOrInit(sName)
h.totalSignals++
h.spansCount++
if (isSpanError(s)) {
h.errorCount++
h.hasErrors = true
}
}
})
})
})
})
return health
}, [filteredLogs, filteredTraces])
useEffect(() => {
// Do not start protected history/WebSocket traffic until a session exists.
// The auth callback reloads the app after storing tokens, so this is also
// sufficient for the normal login flow.
if (!tokens()) return
const fetchHistory = async () => {
try {
// Fetch each signal type separately with its own generous limit,
// rather than one combined /api/history call - otherwise a noisy
// signal type (metrics in particular) can crowd the others out of
// the initial window.
const [tracesRes, logsRes, metricsRes] = await Promise.all([
authFetch("/api/history/traces?limit=300"),
authFetch("/api/history/logs?limit=300"),
authFetch("/api/history/metrics?limit=150"),
])
const [tracesData, logsData, metricsData] = await Promise.all([
tracesRes.json() as Promise<OtelHistoryMessage[] | null>,
logsRes.json() as Promise<OtelHistoryMessage[] | null>,
metricsRes.json() as Promise<OtelHistoryMessage[] | null>,
])
const partitioned = partitionMessages([
...(tracesData ?? []),
...(logsData ?? []),
...(metricsData ?? []),
])
console.log(
"Fetched historical data:",
partitioned.traces.length, "traces,",
partitioned.logs.length, "logs,",
partitioned.metrics.length, "metrics",
)
setTraces(partitioned.traces)
setMetrics(partitioned.metrics)
setLogs(partitioned.logs)
setLogsByTraceId(partitioned.logsByTraceId)
setKnownTraceIds(partitioned.traceIds)
} catch (error) {
console.error("Error fetching history:", error)
}
}
const connect = () => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const t = tokens()
const tokenQuery = t?.accessToken ? `?access_token=${encodeURIComponent(t.accessToken)}` : ""
const wsUrl = `${protocol}//${window.location.host}/ws${tokenQuery}`
console.log(`Connecting to WebSocket at ${wsUrl}`)
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
console.log("WebSocket connected")
setIsConnected(true)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
// Server issues notification broadcast
if (data?.type === "issues") {
setIssuesVersion((v) => v + 1)
return
}
// If stream is paused, buffer the incoming signal
if (!isStreamingRef.current) {
bufferedMessagesRef.current.push(data as OtelMessage)
setBufferedCount(bufferedMessagesRef.current.length)
return
}
const partitioned = partitionMessages([data as OtelMessage])
if (partitioned.traces.length) {
setTraces((prev) => [...prev, ...partitioned.traces])
}
if (partitioned.metrics.length) {
setMetrics((prev) => [...prev, ...partitioned.metrics])
}
if (partitioned.logs.length) {
setLogs((prev) => [...prev, ...partitioned.logs])
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
}
if (partitioned.traceIds.size) {
setKnownTraceIds((prev) => {
const next = new Set(prev)
partitioned.traceIds.forEach((id) => next.add(id))
return next
})
}
} catch (error) {
console.error("Error parsing WebSocket message:", error)
}
}
ws.onerror = (error) => {
console.error("WebSocket error:", error)
setIsConnected(false)
}
ws.onclose = () => {
console.log("WebSocket disconnected, reconnecting in 5s...")
setIsConnected(false)
// Reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, 5000)
}
}
fetchHistory().then(() => connect())
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
}
}, [flushBufferedStream])
// On-demand trace fetch: if the selected trace id isn't one we already
// have spans for (e.g. it arrived before this session connected, or fell
// outside the initial history window), fetch it directly instead of
// silently showing an empty trace view.
useEffect(() => {
if (!tokens() || !activeTraceId || knownTraceIds.has(activeTraceId)) return
let cancelled = false
authFetch(`/api/traces/${encodeURIComponent(activeTraceId)}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<OtelHistoryMessage[] | null>
})
.then((data) => {
if (cancelled) return
const partitioned = partitionMessages(data ?? [])
if (partitioned.traces.length) {
setTraces((prev) => [...prev, ...partitioned.traces])
}
if (partitioned.metrics.length) {
setMetrics((prev) => [...prev, ...partitioned.metrics])
}
if (partitioned.logs.length) {
setLogs((prev) => [...prev, ...partitioned.logs])
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
}
setKnownTraceIds((prev) => {
const next = new Set(prev)
partitioned.traceIds.forEach((id) => next.add(id))
next.add(activeTraceId)
return next
})
})
.catch((error) => {
console.error(`Error fetching trace ${activeTraceId}:`, error)
})
return () => {
cancelled = true
}
}, [activeTraceId, knownTraceIds])
return (
<ObservabilityContext.Provider
value={{
logs: filteredLogs,
metrics: filteredMetrics,
traces: filteredTraces,
unfocusedLogs,
unfocusedTraces,
logsByTraceId,
logCountBySpanId,
isConnected,
// Real stream controls
isStreaming,
setIsStreaming,
bufferedCount,
flushBufferedStream,
// Real time window & latency
timeWindow,
setTimeWindow,
selectedTimeRange,
setSelectedTimeRange,
minDurationMs,
setMinDurationMs,
// Discovered service health & issues updates
serviceHealth,
issuesVersion,
selectedServices,
toggleService,
excludedAttributes,
toggleExcludedValue,
removeExcludedValue,
clearAttributeKey,
resetFilters,
uniqueServices,
showErrorsOnly,
setShowErrorsOnly,
searchQuery,
setSearchQuery,
activeTraceId,
setActiveTraceId,
activeSpanId,
setActiveSpanId,
activeTab,
setActiveTab: setActiveTabWithRoute,
filtersExpanded,
setFiltersExpanded,
}}
>
{children}
</ObservabilityContext.Provider>
)
}
@@ -0,0 +1,88 @@
"use client";
import { createContext } from "react";
import type { LogEntry, MetricData, TraceData } from "../lib/types";
export interface FilterState {
selectedServices: Set<string>;
excludedAttributes: Record<string, Set<string>>;
}
export type TimeWindow = "15m" | "1h" | "6h" | "24h" | "all";
export interface ServiceHealthSummary {
name: string;
totalSignals: number;
errorCount: number;
spansCount: number;
logsCount: number;
hasErrors: boolean;
}
export interface TimeRangeFilter {
startMs: number;
endMs: number;
label?: string;
}
interface ObservabilityContextType {
logs: LogEntry[];
metrics: MetricData[];
traces: TraceData[];
unfocusedLogs: LogEntry[];
unfocusedTraces: TraceData[];
logsByTraceId: Record<string, LogEntry[]>;
logCountBySpanId: Record<string, number>;
isConnected: boolean;
// Real Streaming / Live Tail Controls
isStreaming: boolean;
setIsStreaming: (streaming: boolean) => void;
bufferedCount: number;
flushBufferedStream: () => void;
// Real Time Window Filter
timeWindow: TimeWindow;
setTimeWindow: (window: TimeWindow) => void;
// Selected Time Period Filter (from waveform or custom selection)
selectedTimeRange: TimeRangeFilter | null;
setSelectedTimeRange: (range: TimeRangeFilter | null) => void;
// Latency / Anomaly Threshold Filter
minDurationMs: number;
setMinDurationMs: (ms: number) => void;
// Service Health & Facets
serviceHealth: Record<string, ServiceHealthSummary>;
issuesVersion: number;
// Global filters
selectedServices: Set<string>;
toggleService: (service: string) => void;
excludedAttributes: Record<string, Set<string>>;
toggleExcludedValue: (key: string, value: string) => void;
removeExcludedValue: (key: string, value: string) => void;
clearAttributeKey: (key: string) => void;
resetFilters: () => void;
uniqueServices: string[];
showErrorsOnly: boolean;
setShowErrorsOnly: (show: boolean) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
// Active selection
activeTraceId: string | null;
setActiveTraceId: (id: string | null) => void;
activeSpanId: string | null;
setActiveSpanId: (id: string | null) => void;
activeTab: string;
setActiveTab: (tab: string) => void;
filtersExpanded: boolean;
setFiltersExpanded: (expanded: boolean) => void;
}
export const ObservabilityContext = createContext<
ObservabilityContextType | undefined
>(undefined);
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useEffect } from "react"
import useSWR from "swr"
import type { Issue } from "../lib/types"
import { authSWRFetcher, tokens } from "../lib/auth"
import { useObservabilityData } from "./useObservabilityData"
const FALLBACK_POLL_INTERVAL_MS = 30_000
const LIVE_REFRESH_DEBOUNCE_MS = 1_000
let lastLiveRefreshAt = 0
// Polls the backend's deduplicated error view (GET /api/issues) rather than
// deriving "errors" from the raw telemetry stream in the browser, so counts,
// first/last-seen and dedup match what the server has actually persisted -
// not just whatever fits in this tab's in-memory window. Updates arrive live
// over the WebSocket (useLiveIssues); the interval is only a fallback for
// when the socket is down.
export function useIssues() {
const key = tokens() ? "/api/issues?limit=200" : null
const { data, error, isLoading, mutate } = useSWR<Issue[] | null>(
key,
authSWRFetcher,
{
refreshInterval: FALLBACK_POLL_INTERVAL_MS,
revalidateOnFocus: false,
shouldRetryOnError: false,
isPaused: () => !tokens(),
},
)
const refresh = useCallback(() => mutate(), [mutate])
const { issuesVersion } = useObservabilityData()
// Live push over the primary WebSocket
useEffect(() => {
if (issuesVersion > 0 && tokens()) {
const now = Date.now()
if (now - lastLiveRefreshAt < LIVE_REFRESH_DEBOUNCE_MS) return
lastLiveRefreshAt = now
void refresh()
}
}, [issuesVersion, refresh])
return {
issues: data ?? [],
loading: isLoading,
error: error instanceof Error ? error.message : error ? String(error) : null,
refresh,
}
}
+84
View File
@@ -0,0 +1,84 @@
// Live issue updates: the backend broadcasts "issues" events over the same
// WebSocket as telemetry, so the Issues tab can refresh instantly when an
// error is ingested instead of waiting for the next poll tick. Falls back to
// polling if the socket misbehaves.
import { useEffect, useState } from "react"
export function useLiveIssues(onRefresh: () => void) {
const [live, setLive] = useState(false)
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
let ws: WebSocket | null = null
let retryTimer: number | null = null
let pollTimer: number | null = null
let disposed = false
const startPolling = () => {
if (pollTimer === null) {
pollTimer = window.setInterval(onRefresh, 30_000)
}
}
const stopPolling = () => {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
const connect = () => {
if (disposed) return
try {
ws = new WebSocket(`${protocol}//${window.location.host}/ws`)
} catch {
startPolling()
return
}
ws.onopen = () => {
setLive(true)
stopPolling()
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg?.type === "issues") onRefresh()
} catch {
/* ignore malformed frames */
}
}
ws.onclose = () => {
setLive(false)
startPolling()
// Reuse the telemetry socket's cadence for reconnects.
retryTimer = window.setTimeout(connect, 5000)
}
ws.onerror = () => {
try {
ws?.close()
} catch {
/* ignore */
}
}
}
connect()
return () => {
disposed = true
if (retryTimer !== null) window.clearTimeout(retryTimer)
stopPolling()
if (ws) {
ws.onclose = null
try {
ws.close()
} catch {
/* ignore */
}
}
}
}, [onRefresh])
// Exposed so the Issues header can show a "live" vs "polling" indicator.
return live
}
+7
View File
@@ -0,0 +1,7 @@
import { useMemo } from "react";
import { useObservabilityData } from "./useObservabilityData";
export const useLogsForTraceId = (traceId: string) => {
const { logsByTraceId } = useObservabilityData();
return useMemo(() => logsByTraceId[traceId] || [], [logsByTraceId, traceId]);
};
+129
View File
@@ -0,0 +1,129 @@
import { useMemo } from "react";
import { useObservabilityData } from "./useObservabilityData";
import type { MetricData, MetricDataPoint } from "../lib/types";
import { getPodName } from "../lib/otel-utils";
export interface MetricDetails {
id: string;
name: string;
service: string;
type: "gauge" | "sum" | "histogram";
points: MetricDataPoint[];
unit?: string;
pods: string[];
}
export const useMetricsDetails = (): Record<string, MetricDetails> => {
const { metrics } = useObservabilityData();
return useMemo(() => {
const allMetrics: Record<
string,
{
id: string;
name: string;
service: string;
type: "gauge" | "sum" | "histogram";
points: MetricDataPoint[];
unit?: string;
podsSet: Set<string>;
}
> = {};
metrics.forEach((metricData: MetricData) => {
(metricData.resourceMetrics ?? []).forEach((resourceMetric) => {
const resourceAttrs = resourceMetric.resource?.attributes ?? [];
const serviceName =
resourceAttrs.find((attr) => attr.key === "service.name")?.value.stringValue || "unknown-service";
const resourcePod = getPodName(resourceAttrs);
(resourceMetric.scopeMetrics ?? []).forEach((scopeMetric) => {
(scopeMetric.metrics ?? []).forEach((metric) => {
const key = `${serviceName}:${metric.name}`;
if (!allMetrics[key]) {
let detectedType: "gauge" | "sum" | "histogram" = "sum";
if (metric.gauge) detectedType = "gauge";
else if (metric.histogram) detectedType = "histogram";
allMetrics[key] = {
id: key,
name: metric.name,
service: serviceName,
type: detectedType,
points: [],
unit: (metric as any).unit || undefined,
podsSet: new Set<string>(),
};
}
const enrichPoint = (p: MetricDataPoint): MetricDataPoint => {
const pod = getPodName(undefined, p.attributes) || resourcePod;
if (pod) {
allMetrics[key].podsSet.add(pod);
}
return {
...p,
pod: pod || undefined,
};
};
if (metric.gauge) {
allMetrics[key].type = "gauge";
allMetrics[key].points.push(...(metric.gauge.dataPoints ?? []).map(enrichPoint));
}
if (metric.sum) {
if (allMetrics[key].type !== "gauge") {
allMetrics[key].type = "sum";
}
allMetrics[key].points.push(...(metric.sum.dataPoints ?? []).map(enrichPoint));
}
if (metric.histogram) {
if (allMetrics[key].type !== "gauge") {
allMetrics[key].type = "histogram";
}
const histogramPoints = (metric.histogram.dataPoints ?? []).map((point) =>
enrichPoint({
...point,
attributes: point.attributes ?? [],
asDouble: point.sum ?? 0,
})
);
allMetrics[key].points.push(...histogramPoints);
}
});
});
});
});
const result: Record<string, MetricDetails> = {};
for (const key in allMetrics) {
const sorted = allMetrics[key].points.sort((a, b) => {
const aT = BigInt(a.timeUnixNano || "0");
const bT = BigInt(b.timeUnixNano || "0");
if (aT < bT) return -1;
if (aT > bT) return 1;
return 0;
});
result[key] = {
id: key,
name: allMetrics[key].name,
service: allMetrics[key].service,
type: allMetrics[key].type,
points: sorted.slice(-600),
unit: allMetrics[key].unit,
pods: Array.from(allMetrics[key].podsSet).sort(),
};
}
return result;
}, [metrics]);
};
export const useMetrics = () => {
const details = useMetricsDetails();
return useMemo(() => {
const grouped: Record<string, MetricDataPoint[]> = {};
for (const key in details) {
grouped[key] = details[key].points;
}
return grouped;
}, [details]);
};
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useContext } from "react";
import { ObservabilityContext } from "../contexts/ObservabilityContextValue";
export function useObservabilityData() {
const context = useContext(ObservabilityContext);
if (!context) {
throw new Error(
"useObservabilityData must be used within an ObservabilityProvider",
);
}
return context;
}
+41
View File
@@ -0,0 +1,41 @@
import useSWR from "swr"
import { authSWRFetcher, authFetch, tokens } from "../lib/auth"
import type { Issue, IssuePattern, SimilarIssue } from "../lib/types"
export function usePatterns() {
const enabled = Boolean(tokens())
const patterns = useSWR<IssuePattern[]>(enabled ? "/api/patterns" : null, authSWRFetcher, {
revalidateOnFocus: false,
refreshInterval: 30_000,
})
const issues = useSWR<Issue[]>(enabled ? "/api/issues?limit=200" : null, authSWRFetcher, {
revalidateOnFocus: false,
refreshInterval: 30_000,
})
const similar = async (id: string) => {
const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar?limit=8`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return (await response.json()) as SimilarIssue[]
}
const review = async (id: string, otherId: string, decision: "same" | "different") => {
const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issueId: otherId, decision }),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
await Promise.all([patterns.mutate(), issues.mutate()])
}
return {
patterns: patterns.data ?? [],
issues: issues.data ?? [],
loading: patterns.isLoading || issues.isLoading,
error: patterns.error ?? issues.error,
refresh: () => Promise.all([patterns.mutate(), issues.mutate()]),
similar,
review,
}
}
+14
View File
@@ -0,0 +1,14 @@
import useSWR from "swr"
import { authSWRFetcher, authFetch, tokens } from "../lib/auth"
import type { Rollout, RolloutImpact } from "../lib/types"
export function useRollouts() {
const key = tokens() ? "/api/rollouts" : null
const state = useSWR<Rollout[]>(key, authSWRFetcher, { refreshInterval: 30_000, revalidateOnFocus: false, shouldRetryOnError: false })
const impact = async (id: string) => {
const response = await authFetch(`/api/rollouts/${encodeURIComponent(id)}/impact`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return (await response.json()) as RolloutImpact
}
return { rollouts: state.data ?? [], loading: state.isLoading, error: state.error, refresh: state.mutate, impact }
}
+60
View File
@@ -0,0 +1,60 @@
import { useCallback } from "react"
import useSWR from "swr"
import type { ErrorStatsResponse, ReleaseStatsResponse } from "../lib/types"
import { authFetch, authSWRFetcher, tokens } from "../lib/auth"
/**
* True when the backend has ClickHouse-backed stats (the endpoints answer
* 200 instead of the documented 501). null while checking, so the Trends
* tab can hide itself entirely on file-store deployments.
*/
export function useStatsAvailability() {
const { data } = useSWR(tokens() ? "/api/stats/services" : null, async (key) => {
const res = await authFetch(key)
return res.ok
}, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() })
return data ?? null
}
export interface StatsRange {
/** Lookback window in hours; server caps at 2160 (90 days). */
hours: number
service: string
}
const DEFAULT_RANGE: StatsRange = { hours: 72, service: "" }
/**
* Fetches both error-trend and release-comparison series for the selected
* range. Both endpoints come from the same ClickHouse rollup, so a single
* refresh covers the tab.
*/
export function useStats(range: StatsRange = DEFAULT_RANGE) {
const params = new URLSearchParams({ hours: String(range.hours) })
if (range.service) params.set("service", range.service)
const key = tokens() ? `/api/stats/errors?${params}` : null
const { data, error, isLoading, mutate } = useSWR<{
errors: ErrorStatsResponse
releases: ReleaseStatsResponse | null
services: string[]
}>(key, async () => {
const [errors, releases, services] = await Promise.all([
authSWRFetcher<ErrorStatsResponse>(`/api/stats/errors?${params}`),
authSWRFetcher<ReleaseStatsResponse>(`/api/stats/releases?${params}`).catch(() => null),
authSWRFetcher<{ services?: string[] }>("/api/stats/services").catch(() => null),
])
return { errors, releases, services: services?.services ?? [] }
}, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() })
const refresh = useCallback(() => mutate(), [mutate])
return {
errorPoints: data?.errors.points ?? [],
granularity: data?.errors.granularity ?? "hour",
releaseSeries: data?.releases?.series ?? {},
services: data?.services ?? [],
loading: isLoading,
error: error instanceof Error ? error.message : error ? String(error) : null,
refresh,
}
}
+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
@theme {
--color-background: #0f131c;
--color-surface: #0f131c;
--color-surface-dim: #0f131c;
--color-surface-bright: #353942;
--color-surface-container-lowest: #0a0e16;
--color-surface-container-low: #181c24;
--color-surface-container: #1c2028;
--color-surface-container-high: #262a33;
--color-surface-container-highest: #31353e;
--color-surface-variant: #31353e;
--color-surface-tint: #00dbe9;
--color-on-background: #dfe2ee;
--color-on-surface: #dfe2ee;
--color-on-surface-variant: #b9cacb;
--color-inverse-surface: #dfe2ee;
--color-inverse-on-surface: #2c3039;
--color-primary: #dbfcff;
--color-primary-container: #00f0ff;
--color-on-primary: #00363a;
--color-on-primary-container: #006970;
--color-primary-fixed: #7df4ff;
--color-primary-fixed-dim: #00dbe9;
--color-on-primary-fixed: #002022;
--color-on-primary-fixed-variant: #004f54;
--color-inverse-primary: #006970;
--color-secondary: #4edea3;
--color-secondary-container: #00a572;
--color-on-secondary: #003824;
--color-on-secondary-container: #00311f;
--color-secondary-fixed: #6ffbbe;
--color-secondary-fixed-dim: #4edea3;
--color-on-secondary-fixed: #002113;
--color-on-secondary-fixed-variant: #005236;
--color-tertiary: #fff3f3;
--color-tertiary-container: #ffcdd1;
--color-on-tertiary: #670020;
--color-on-tertiary-container: #be0042;
--color-tertiary-fixed: #ffd9dc;
--color-tertiary-fixed-dim: #ffb2ba;
--color-on-tertiary-fixed: #400011;
--color-on-tertiary-fixed-variant: #910030;
--color-error: #ffb4ab;
--color-error-container: #93000a;
--color-on-error: #690005;
--color-on-error-container: #ffdad6;
--color-outline: #849495;
--color-outline-variant: #3b494b;
--font-headline: "Geist", sans-serif;
--font-body: "Inter", sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
@layer base {
html, body {
margin: 0;
padding: 0;
background-color: #0f131c;
color: #dfe2ee;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
overscroll-behavior: none;
}
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 20px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
+240
View File
@@ -0,0 +1,240 @@
// OIDC authorization-code flow with PKCE against the go-auth service.
// Client is public (no secret); tokens live in localStorage.
const ISSUER =
(import.meta.env.VITE_OIDC_ISSUER as string | undefined) ??
"https://users.k6n.net";
const CLIENT_ID =
(import.meta.env.VITE_OIDC_CLIENT_ID as string | undefined) ?? "otel-debug";
const SCOPE = "openid profile email offline_access";
const TOKEN_KEY = "otel.auth.tokens";
export interface Tokens {
accessToken: string;
idToken: string;
refreshToken?: string;
expiresAt: number; // epoch ms
}
export interface UserClaims {
sub: string;
name?: string;
email?: string;
roles?: string[];
}
export class AuthRequiredError extends Error {
constructor() {
super("Authentication required")
this.name = "AuthRequiredError"
}
}
function b64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function readTokens(): Tokens | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(TOKEN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as Tokens;
} catch {
return null;
}
}
// tokens() returns tokens if the access token is currently valid.
export function tokens(): Tokens | null {
const t = readTokens();
return t && t.expiresAt > Date.now() + 10_000 ? t : null;
}
export function hasSession(): boolean {
return readTokens() !== null;
}
let refreshing: Promise<Tokens | null> | null = null;
// ensureTokens() returns valid tokens, refreshing if close to expiration.
export async function ensureTokens(): Promise<Tokens | null> {
const t = readTokens();
if (!t) return null;
if (t.expiresAt > Date.now() + 30_000) return t;
if (!t.refreshToken) return null;
refreshing ??= doRefresh(t).finally(() => {
refreshing = null;
});
return refreshing;
}
async function doRefresh(t: Tokens): Promise<Tokens | null> {
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: t.refreshToken!,
}),
});
if (!res.ok) {
localStorage.removeItem(TOKEN_KEY);
return null;
}
const body = (await res.json()) as TokenResponse;
saveTokens(body, t.refreshToken);
return readTokens();
} catch {
return null;
}
}
interface TokenResponse {
access_token: string;
id_token: string;
refresh_token?: string;
expires_in: number;
}
function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
if (typeof window === "undefined") return;
localStorage.setItem(
TOKEN_KEY,
JSON.stringify({
accessToken: body.access_token,
idToken: body.id_token,
refreshToken: body.refresh_token ?? fallbackRefresh,
expiresAt: Date.now() + body.expires_in * 1000,
} satisfies Tokens),
);
}
export function currentUser(): UserClaims | null {
const t = tokens();
if (!t || !t.idToken) return null;
try {
const payload = t.idToken.split(".")[1];
return JSON.parse(
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
) as UserClaims;
} catch {
return null;
}
}
export function logout() {
if (typeof window === "undefined") return;
localStorage.removeItem(TOKEN_KEY);
const end = `${ISSUER}/oidc/v1/end_session`;
window.location.assign(
`${end}?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}`,
);
}
function redirectUri(): string {
return `${window.location.origin}/auth_callback`;
}
export async function login() {
if (typeof window === "undefined") return;
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = b64url(verifierBytes);
const challenge = b64url(
new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
),
),
);
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem(
"otel.auth.pkce",
JSON.stringify({
verifier,
state,
back: window.location.pathname + window.location.search,
}),
);
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: redirectUri(),
response_type: "code",
scope: SCOPE,
code_challenge: challenge,
code_challenge_method: "S256",
state,
});
window.location.assign(`${ISSUER}/oauth/v2/authorize?${params}`);
}
export async function handleCallback(): Promise<string | null> {
if (typeof window === "undefined") return null;
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return null;
const saved = JSON.parse(
sessionStorage.getItem("otel.auth.pkce") ?? "null",
) as { verifier: string; state: string; back: string } | null;
sessionStorage.removeItem("otel.auth.pkce");
if (!saved || state !== saved.state) {
throw new Error("OIDC state mismatch");
}
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
redirect_uri: redirectUri(),
code,
code_verifier: saved.verifier,
}),
});
if (!res.ok) {
throw new Error(`Token exchange failed with status ${res.status}`);
}
saveTokens((await res.json()) as TokenResponse);
const destination = saved.back || "/";
window.history.replaceState(null, "", destination);
return destination;
}
export async function authFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const t = await ensureTokens();
// Protected API callers must never send anonymous requests. Apart from
// avoiding unnecessary 401s, this prevents the RUM error reporter from
// turning an expired session into an issues-refresh loop.
if (!t?.accessToken) throw new AuthRequiredError()
const headers = new Headers(init?.headers);
if (t?.accessToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${t.accessToken}`);
}
return fetch(input, { ...init, headers });
}
export async function authSWRFetcher<T>(key: string): Promise<T> {
const response = await authFetch(key)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<T>
}
+85
View File
@@ -0,0 +1,85 @@
// Shared message-partitioning logic used by every ingestion path: the
// initial history fetch, live WebSocket messages, and on-demand trace
// fetches (GET /api/traces/{traceId}). Keeping this in one place means all
// three stay consistent instead of drifting.
import type { LogEntry, MetricData, OtelMessage, TraceData } from "./types"
import { normalizeLogEntry, normalizeMetricData, normalizeTrace } from "./normalize"
export interface PartitionedMessages {
traces: TraceData[]
metrics: MetricData[]
logs: LogEntry[]
logsByTraceId: Record<string, LogEntry[]>
traceIds: Set<string>
}
export function partitionMessages(messages: OtelMessage[]): PartitionedMessages {
const traces: TraceData[] = []
const metrics: MetricData[] = []
const logs: LogEntry[] = []
const logsByTraceId: Record<string, LogEntry[]> = {}
const traceIds = new Set<string>()
messages.forEach((message) => {
switch (message.type) {
case "traces": {
const normalized = normalizeTrace(message.data)
traces.push(normalized)
normalized.resourceSpans.forEach((rs) =>
rs.scopeSpans.forEach((ss) =>
ss.spans.forEach((span) => {
if (span.traceId) traceIds.add(span.traceId)
}),
),
)
break
}
case "metrics":
metrics.push(normalizeMetricData(message.data))
break
case "logs": {
const logsWithId = (message.data.resourceLogs ?? []).map((log) =>
normalizeLogEntry({
...log,
id: crypto.randomUUID(),
scopeLogs: (log.scopeLogs ?? []).map((sl) => ({
...sl,
logRecords: (sl.logRecords ?? []).map((record) => ({
...record,
uid: record.uid ?? crypto.randomUUID(),
})),
})),
}),
)
logsWithId.forEach((log) => {
logs.push(log)
log.scopeLogs.forEach((scopeLog) => {
scopeLog.logRecords.forEach((record) => {
if (record.traceId) {
if (!logsByTraceId[record.traceId]) {
logsByTraceId[record.traceId] = []
}
logsByTraceId[record.traceId].push(log)
}
})
})
})
break
}
}
})
return { traces, metrics, logs, logsByTraceId, traceIds }
}
export function mergeLogsByTraceId(
prev: Record<string, LogEntry[]>,
additions: Record<string, LogEntry[]>,
): Record<string, LogEntry[]> {
if (Object.keys(additions).length === 0) return prev
const next = { ...prev }
Object.entries(additions).forEach(([traceId, entries]) => {
next[traceId] = [...(next[traceId] ?? []), ...entries]
})
return next
}
+95
View File
@@ -0,0 +1,95 @@
// Helpers for presenting backend issues in the UI: trend sparkline data,
// breadcrumb parsing and triage-state presentation.
import type { Issue, IssueSample } from "./types"
export interface TrendPoint {
hour: string
count: number
}
/**
* Converts an issue's sparse hourly trend map into a dense series covering
* the full look-back window, oldest first - ready for a sparkline.
*/
export function trendPoints(issue: Issue, hours = 24): TrendPoint[] {
const now = new Date()
const currentHour = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours()),
)
const points: TrendPoint[] = []
for (let i = hours - 1; i >= 0; i--) {
const d = new Date(currentHour.getTime() - i * 3_600_000)
const hour = d.toISOString().slice(0, 13) + ":00"
points.push({ hour, count: issue.trend?.[hour] ?? 0 })
}
return points
}
/** True when the issue has at least one occurrence in the last hour. */
export function isSpike(issue: Issue): boolean {
const points = trendPoints(issue, 1)
return (points[0]?.count ?? 0) > 1
}
export interface ParsedBreadcrumb {
t: number
category: string
message: string
}
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
export function parseBreadcrumbs(raw?: string): ParsedBreadcrumb[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(b): b is ParsedBreadcrumb =>
b && typeof b === "object" && typeof b.category === "string" && typeof b.message === "string",
)
} catch {
return []
}
}
export type IssueStatus = "open" | "resolved" | "ignored"
export const issueStatusMeta: Record<IssueStatus, { label: string; className: string }> = {
open: { label: "Open", className: "bg-rose-500/10 text-rose-300 border-rose-500/20" },
resolved: {
label: "Resolved",
className: "bg-emerald-500/10 text-emerald-300 border-emerald-500/20",
},
ignored: { label: "Ignored", className: "bg-slate-500/10 text-slate-400 border-white/10" },
}
export function issueStatusOf(issue: Issue): IssueStatus {
if (issue.status === "resolved" || issue.status === "ignored") return issue.status
return "open"
}
/** Most informative stacktrace across an issue's samples, if any. */
export function latestStacktrace(issue: Issue): string | undefined {
for (let i = issue.samples.length - 1; i >= 0; i--) {
if (issue.samples[i]?.resolvedStacktrace) return issue.samples[i].resolvedStacktrace
if (issue.samples[i]?.stacktrace) return issue.samples[i].stacktrace
}
return undefined
}
/** Latest sample carrying breadcrumbs (frontend issues), if any. */
export function latestBreadcrumbs(issue: Issue): ParsedBreadcrumb[] {
for (let i = issue.samples.length - 1; i >= 0; i--) {
const parsed = parseBreadcrumbs(issue.samples[i]?.breadcrumbs)
if (parsed.length) return parsed
}
return []
}
export function sampleContext(sample: IssueSample): string[] {
const parts: string[] = []
if (sample.httpMethod) parts.push(sample.httpMethod)
if (sample.httpRoute) parts.push(sample.httpRoute)
if (sample.httpStatus) parts.push(String(sample.httpStatus))
return parts
}
+74
View File
@@ -0,0 +1,74 @@
// OTLP JSON exporters omit fields when empty, so any of the arrays below can
// be undefined at runtime even though the types say otherwise. Normalize every
// message once at ingestion so consumers can rely on the declared types.
import type { LogEntry, LogRecord, MetricData, TraceData } from "./types"
const attrList = (value: unknown): LogRecord["attributes"] =>
Array.isArray(value) ? (value as LogRecord["attributes"]) : []
const normalizeLogRecord = (record: any): LogRecord => ({
...record,
attributes: attrList(record?.attributes),
})
export const normalizeLogEntry = (log: any): LogEntry => ({
...log,
id: log?.id ?? crypto.randomUUID(),
resource: {
...log?.resource,
attributes: attrList(log?.resource?.attributes),
},
scopeLogs: attrList(log?.scopeLogs).map((scopeLog: any) => ({
...scopeLog,
scope: scopeLog?.scope ?? { name: "unknown" },
logRecords: attrList(scopeLog?.logRecords).map(normalizeLogRecord),
})),
})
export const normalizeTrace = (trace: any): TraceData => ({
...trace,
resourceSpans: attrList(trace?.resourceSpans).map((resourceSpan: any) => ({
...resourceSpan,
resource: {
...resourceSpan?.resource,
attributes: attrList(resourceSpan?.resource?.attributes),
},
scopeSpans: attrList(resourceSpan?.scopeSpans).map((scopeSpan: any) => ({
...scopeSpan,
scope: scopeSpan?.scope ?? { name: "unknown" },
spans: attrList(scopeSpan?.spans).map((span: any) => ({
...span,
attributes: attrList(span?.attributes),
events: attrList(span?.events),
status: span?.status ?? {},
})),
})),
})),
})
export const normalizeMetricData = (data: any): MetricData => ({
...data,
resourceMetrics: attrList(data?.resourceMetrics).map((resourceMetric: any) => ({
...resourceMetric,
resource: {
...resourceMetric?.resource,
attributes: attrList(resourceMetric?.resource?.attributes),
},
scopeMetrics: attrList(resourceMetric?.scopeMetrics).map((scopeMetric: any) => ({
...scopeMetric,
scope: scopeMetric?.scope ?? { name: "unknown" },
metrics: attrList(scopeMetric?.metrics).map((metric: any) => ({
...metric,
gauge: metric?.gauge
? { ...metric.gauge, dataPoints: attrList(metric.gauge.dataPoints) }
: undefined,
sum: metric?.sum
? { ...metric.sum, dataPoints: attrList(metric.sum.dataPoints) }
: undefined,
histogram: metric?.histogram
? { ...metric.histogram, dataPoints: attrList(metric.histogram.dataPoints) }
: undefined,
})),
})),
})),
})
+209
View File
@@ -0,0 +1,209 @@
import type { AttributeValue, OtelAttribute } from "./types";
export function getAttributeValue(
attributes: OtelAttribute[],
key: string,
): string {
const lowerKey = key.toLowerCase();
const attr = attributes.find((a) => a.key === key || a.key.toLowerCase() === lowerKey);
if (!attr) return "";
const { value } = attr;
if (value.stringValue) return value.stringValue;
if (value.intValue) return value.intValue;
if (value.doubleValue !== undefined) return value.doubleValue.toString();
if (value.boolValue !== undefined) return value.boolValue.toString();
return "";
}
export function formatTimestamp(nanoTimestamp: string): string {
const ms = Number(nanoTimestamp) / 1_000_000;
return new Date(ms).toLocaleString();
}
export function formatDuration(startNano: string, endNano: string): string {
const durationMs = (Number(endNano) - Number(startNano)) / 1_000_000;
if (durationMs < 1) {
return `${(durationMs * 1000).toFixed(2)}μs`;
} else if (durationMs < 1000) {
return `${durationMs.toFixed(2)}ms`;
} else {
return `${(durationMs / 1000).toFixed(2)}s`;
}
}
export function getSpanKindLabel(kind: number): string {
const kinds = [
"UNSPECIFIED",
"INTERNAL",
"SERVER",
"CLIENT",
"PRODUCER",
"CONSUMER",
];
return kinds[kind] || "UNKNOWN";
}
export function getServiceName(
resourceAttributes?: OtelAttribute[],
recordAttributes?: OtelAttribute[],
): string {
const keys = ["service.name", "app.kubernetes.io/name", "app", "name"];
if (resourceAttributes) {
for (const k of keys) {
const v = getAttributeValue(resourceAttributes, k);
if (v && v !== "unknown" && v !== "unknown-service") return v;
}
}
if (recordAttributes) {
for (const k of keys) {
const v = getAttributeValue(recordAttributes, k);
if (v && v !== "unknown" && v !== "unknown-service") return v;
}
}
return "unknown-service";
}
export function isSpanError(span: {
status?: { code?: number };
attributes?: OtelAttribute[];
}): boolean {
if (span.status?.code === 2) return true;
const statusStr =
getAttributeValue(span.attributes ?? [], "http.response.status_code") ||
getAttributeValue(span.attributes ?? [], "http.status_code");
if (statusStr) {
const code = parseInt(statusStr, 10);
if (!isNaN(code) && code >= 400) return true;
}
const grpcStr = getAttributeValue(span.attributes ?? [], "rpc.grpc.status_code");
if (grpcStr) {
const code = parseInt(grpcStr, 10);
if (!isNaN(code) && code > 0) return true;
}
return false;
}
export function getPodName(
resourceAttributes?: OtelAttribute[],
pointAttributes?: OtelAttribute[],
): string {
const keys = [
"k8s.pod.name",
"pod.name",
"pod",
"pod_name",
"k8s_pod_name",
"service.instance.id",
"service_instance_id",
"instance.id",
"k8s.pod.uid",
"container.name",
"container_name",
"container.id",
"host.name",
"hostname",
"host",
"instance",
"process.pid",
];
if (resourceAttributes) {
for (const k of keys) {
const v = getAttributeValue(resourceAttributes, k);
if (v) return v;
}
}
if (pointAttributes) {
for (const k of keys) {
const v = getAttributeValue(pointAttributes, k);
if (v) return v;
}
}
return "";
}
export const POD_COLORS = [
{ stroke: "#38bdf8", fill: "#38bdf8", text: "text-sky-400", bg: "bg-sky-500/10", border: "border-sky-500/20" },
{ stroke: "#a855f7", fill: "#a855f7", text: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20" },
{ stroke: "#34d399", fill: "#34d399", text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
{ stroke: "#fbbf24", fill: "#fbbf24", text: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
{ stroke: "#fb7185", fill: "#fb7185", text: "text-rose-400", bg: "bg-rose-500/10", border: "border-rose-500/20" },
{ stroke: "#22d3ee", fill: "#22d3ee", text: "text-cyan-400", bg: "bg-cyan-500/10", border: "border-cyan-500/20" },
{ stroke: "#f472b6", fill: "#f472b6", text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
{ stroke: "#818cf8", fill: "#818cf8", text: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
];
export function getPodPalette(pod: string) {
return POD_COLORS[hashString(pod) % POD_COLORS.length] ?? POD_COLORS[0];
}
export const formatAttributeValue = (value: AttributeValue): string => {
if (value.stringValue !== undefined) return value.stringValue;
if (value.intValue !== undefined) return value.intValue.toString();
if (value.doubleValue !== undefined) return value.doubleValue.toString();
if (value.boolValue !== undefined) return value.boolValue.toString();
return "unknown";
};
export const servicePalettes = [
{
gradient: "from-violet-600 via-indigo-600 to-sky-600",
ring: "ring-violet-400/40",
text: "text-violet-400",
bg: "bg-violet-400/10",
border: "border-violet-400/20",
},
{
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
ring: "ring-emerald-400/40",
text: "text-emerald-400",
bg: "bg-emerald-400/10",
border: "border-emerald-400/20",
},
{
gradient: "from-amber-600 via-orange-600 to-rose-600",
ring: "ring-amber-400/40",
text: "text-amber-400",
bg: "bg-amber-400/10",
border: "border-amber-400/20",
},
{
gradient: "from-fuchsia-600 via-purple-600 to-indigo-700",
ring: "ring-purple-400/40",
text: "text-fuchsia-400",
bg: "bg-fuchsia-400/10",
border: "border-fuchsia-400/20",
},
{
gradient: "from-sky-500 via-blue-600 to-indigo-700",
ring: "ring-sky-400/40",
text: "text-sky-400",
bg: "bg-sky-400/10",
border: "border-sky-400/20",
},
{
gradient: "from-lime-500 via-emerald-600 to-teal-700",
ring: "ring-lime-400/40",
text: "text-lime-400",
bg: "bg-lime-400/10",
border: "border-lime-400/20",
},
];
export const hashString = (value: string): number => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
};
export const getServicePalette = (service: string) => {
const palette =
servicePalettes[hashString(service) % servicePalettes.length] ??
servicePalettes[0];
return palette;
};
+31
View File
@@ -0,0 +1,31 @@
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
// exactly the view you requested, and URLs are shareable/bookmarkable.
// The Go server's SPA fallback serves index.html for all of these paths.
export const TAB_PATHS: Record<string, string> = {
"/issues": "tab0",
"/patterns": "tab8",
"/rollouts": "tab9",
"/traces": "tab1",
"/metrics": "tab2",
"/logs": "tab3",
"/setup": "tab4",
"/trends": "tab5",
"/search": "tab6",
"/frontend": "tab7",
"/rum": "tab7",
}
/** Tab value for a location pathname, or null when the path isn't a tab. */
export function tabForPath(path: string): string | null {
return TAB_PATHS[path] ?? null
}
/** Canonical path for a tab value, or null when the tab has no route. */
export function pathForTab(tab: string): string | null {
for (const [path, value] of Object.entries(TAB_PATHS)) {
if (value === tab) return path
}
return null
}
+25
View File
@@ -0,0 +1,25 @@
export function formatDuration(nano: number): string {
if (nano >= 3600000000000) { // 1 hour in nanoseconds
return `${(nano / 3600000000000).toFixed(1)}h`;
} else if (nano >= 60000000000) { // 1 minute
return `${(nano / 60000000000).toFixed(1)}m`;
} else if (nano >= 1000000000) { // 1 second
return `${(nano / 1000000000).toFixed(1)}s`;
} else if (nano >= 1000000) { // 1 millisecond
return `${(nano / 1000000).toFixed(1)}ms`;
} else if (nano >= 1000) { // 1 microsecond
return `${(nano / 1000).toFixed(1)}μs`;
} else {
return `${nano.toFixed(1)}ns`;
}
}
/** "just now", "2m ago", "3h ago", "5d ago" */
export function formatRelativeTime(msEpoch: number): string {
const seconds = Math.max(0, (Date.now() - msEpoch) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
+268
View File
@@ -0,0 +1,268 @@
// OpenTelemetry data types
export interface OtelAttribute {
key: string;
value: AttributeValue;
}
export interface AttributeValue {
stringValue?: string;
intValue?: string;
doubleValue?: number;
boolValue?: boolean;
}
export interface SpanEvent {
name: string;
timeUnixNano: string;
attributes: OtelAttribute[];
}
export interface Span {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
kind: number;
startTimeUnixNano: string;
endTimeUnixNano: string;
attributes: OtelAttribute[];
events: SpanEvent[];
status: Record<string, unknown>;
flags: number;
}
export interface ScopeSpan {
scope: {
name: string;
};
spans: Span[];
}
export interface ResourceSpan {
resource: {
attributes: OtelAttribute[];
};
schemaUrl?: string;
scopeSpans: ScopeSpan[];
}
export interface TraceData {
resourceSpans: ResourceSpan[];
}
export interface MetricDataPoint {
asInt?: string;
asDouble?: number;
attributes: OtelAttribute[];
timeUnixNano?: string;
pod?: string;
// Histogram-specific fields
bucketCounts?: string[];
count?: string;
exemplars?: Array<{
asInt?: string;
asDouble?: number;
spanId: string;
timeUnixNano: string;
traceId: string;
}>;
explicitBounds?: number[];
max?: number;
min?: number;
startTimeUnixNano?: string;
sum?: number;
}
export interface Metric {
name: string;
gauge?: {
dataPoints: MetricDataPoint[];
};
sum?: {
dataPoints: MetricDataPoint[];
};
histogram?: {
dataPoints: MetricDataPoint[];
};
}
export interface ScopeMetric {
scope: {
name: string;
};
metrics: Metric[];
}
export interface ResourceMetric {
resource: {
attributes: OtelAttribute[];
};
scopeMetrics: ScopeMetric[];
}
export interface MetricData {
resourceMetrics: ResourceMetric[];
}
export interface LogRecord {
uid: string;
timeUnixNano: string;
body: {
stringValue: string;
};
attributes: OtelAttribute[];
severityNumber?: number;
severityText?: string;
traceId?: string;
spanId?: string;
}
export interface ScopeLog {
scope: {
name: string;
};
logRecords: LogRecord[];
}
export interface ResourceLog {
resource: {
attributes: OtelAttribute[];
};
scopeLogs: ScopeLog[];
}
export type LogEntry = ResourceLog & {
id: string;
}
export interface LogData {
resourceLogs: ResourceLog[];
}
export interface OtelTraceData {
type: "traces";
data: TraceData;
}
export interface OtelMetricData {
type: "metrics";
data: MetricData;
}
export interface OtelLogData {
type: "logs";
data: LogData;
}
export type OtelHistoryMessage = OtelMessage & {
timestamp: number;
};
export type OtelMessage = OtelTraceData | OtelMetricData | OtelLogData;
// Deduplicated errors derived server-side from traces/logs. See
// GET /api/issues and GET /api/issues/{id} in the go-otel backend.
export interface IssueSample {
timestamp: number;
traceId?: string;
spanId?: string;
message: string;
url?: string;
stacktrace?: string;
resolvedStacktrace?: string;
httpMethod?: string;
httpRoute?: string;
httpStatus?: number;
userAgent?: string;
sourceIp?: string;
release?: string;
environment?: string;
/** Raw JSON breadcrumb trail captured by otel-rum.js (frontend issues). */
breadcrumbs?: string;
}
export interface Issue {
id: string;
title: string;
kind: "span_error" | "log_error" | "frontend_error" | string;
service: string;
count: number;
firstSeen: number;
lastSeen: number;
affectedServices: string[];
samples: IssueSample[];
/** UTC hour-bucket ("2026-09-17T14:00") -> occurrence count, last 48h. */
trend?: Record<string, number>;
status?: "open" | "resolved" | "ignored" | string;
resolvedAt?: number;
normalizedTitle?: string;
patternId?: string;
patternSignature?: string;
}
export interface SimilarIssue {
issue: Issue;
score: number;
reasons: string[];
decision?: "same" | "different" | string;
}
export interface IssuePattern {
id: string;
signature: string;
service: string;
kind: string;
issueCount: number;
occurrenceCount: number;
lastSeen: number;
issueIds: string[];
}
export interface Rollout {
id: string;
namespace: string;
workload: string;
service?: string;
release?: string;
revision?: string;
image?: string;
phase: "progressing" | "completed" | "failed" | string;
reason?: string;
message?: string;
startedAt: number;
completedAt?: number;
lastSeen: number;
generation: number;
desiredReplicas: number;
updatedReplicas: number;
availableReplicas: number;
readyReplicas: number;
}
export interface RolloutImpact {
configured: boolean;
rollout: Rollout;
points: Array<{ timestamp: number; value: string }>;
}
// ---- Aggregate stats (ClickHouse-backed /api/stats/*) -------------------
export interface StatsPoint {
/** ISO-ish bucket label from the server, e.g. "2026-09-17 14:00:00". */
bucket: string;
total: number;
errors: number;
}
export interface ErrorStatsResponse {
granularity: "hour" | "day";
points: StatsPoint[];
}
export interface ReleaseStatsResponse {
series: Record<string, StatsPoint[]>;
}
+31
View File
@@ -0,0 +1,31 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Router } from "wouter";
import "./index.css";
import App from "./App.tsx";
import { ObservabilityProvider } from "./contexts/ObservabilityContextProvider.tsx";
import { ViewErrorBoundary } from "./components/ViewErrorBoundary";
// Use the project's own browser telemetry for UI errors, failed requests and
// Web Vitals. The script is loaded before this module in index.html.
const rum = (window as Window & {
OtelRUM?: { init: (config: Record<string, unknown>) => unknown }
}).OtelRUM;
rum?.init({
serviceName: "otel-ui",
release: (import.meta.env.VITE_APP_RELEASE as string | undefined) ?? "dev",
baseUrl: window.location.origin,
endpoint: "/v1/logs",
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Router>
<ObservabilityProvider>
<ViewErrorBoundary>
<App />
</ViewErrorBoundary>
</ObservabilityProvider>
</Router>
</StrictMode>,
);
+125
View File
@@ -0,0 +1,125 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: 'Geist', 'Geist Fallback';
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}