more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -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 >{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: "{searchQuery}"</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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 & 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 & 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 & 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 & Trace</div>
|
||||
<div className="col-span-7">Log Message & 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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>>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
@@ -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 & 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 & 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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user