Files
go-otel/otel-ui/src/components/Tabs.tsx
T
mats 8f670dfa2f
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s
better otel i think
2026-09-19 19:14:51 +02:00

382 lines
14 KiB
TypeScript

import { useCallback, useState } from "react"
import * as Tabs from "@radix-ui/react-tabs"
import {
Activity,
AlertTriangle,
Info,
CheckCircle2,
ChevronDown,
ChevronUp,
} 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 { RumSessionsVisualizer } from "./RumSessionsVisualizer"
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
bar: string
badge: string
ring: string
icon: LucideIcon
}
const severityStyles: Record<string, SeverityMeta> = {
ERROR: {
baseLabel: "Error",
bar: "bg-ds-critical",
badge: "bg-ds-critical-bg text-ds-critical",
ring: "ring-ds-critical/40",
icon: AlertTriangle,
},
WARN: {
baseLabel: "Warning",
bar: "bg-ds-warn",
badge: "bg-ds-warn-bg text-ds-warn",
ring: "ring-ds-warn/40",
icon: AlertTriangle,
},
INFO: {
baseLabel: "Info",
bar: "bg-ds-accent",
badge: "bg-ds-accent-bg text-ds-accent",
ring: "ring-ds-accent/40",
icon: Info,
},
DEBUG: {
baseLabel: "Debug",
bar: "bg-ds-ok",
badge: "bg-ds-ok/15 text-ds-ok",
ring: "ring-ds-ok/40",
icon: CheckCircle2,
},
}
const fallbackSeverity: SeverityMeta = {
baseLabel: "Log",
bar: "bg-ds-line-strong",
badge: "bg-ds-surface-2 text-ds-text-2",
ring: "ring-ds-line-strong",
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-ds-line bg-ds-surface-2 transition-all hover:border-ds-line hover:bg-ds-surface-2"
>
{/* 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 ${severity.bar} opacity-40 transition-opacity group-hover:opacity-100`} />
{/* Timestamp */}
<span className="shrink-0 text-ds-text-3 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 ${severity.badge}`}>
{severity.display}
</span>
{/* Log Message */}
<span className="flex-1 min-w-[300px] break-all text-ds-text">
{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-ds-accent-bg border border-ds-accent/20 px-2.5 py-0.5 text-[10px] font-bold text-ds-accent hover:bg-ds-accent-bg hover:border-ds-accent/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-ds-text-3" /> : <ChevronDown className="h-3 w-3 text-ds-text-3" />}
</div>
</div>
{/* Expansion Content */}
{isExpanded && (
<div className="border-t border-ds-line bg-ds-surface-2 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-ds-text-3">Attributes</p>
<div className="space-y-1">
{(record.attributes ?? []).map(attr => (
<div key={attr.key} className="flex gap-2">
<span className="text-ds-accent shrink-0">{attr.key}:</span>
<span className="text-ds-text-2 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-ds-text-3">Resource</p>
<div className="space-y-1">
{(record.resourceAttributes ?? []).map(attr => (
<div key={attr.key} className="flex gap-2">
<span className="text-ds-ok shrink-0">{attr.key}:</span>
<span className="text-ds-text-2 break-all">{formatAttributeValue(attr.value)}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-ds-line flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-4 text-[10px] text-ds-text-3">
<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-ds-accent px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-ds-on-accent transition-all hover:bg-ds-accent hover:scale-[1.02] active:scale-[0.98]"
>
<Activity className="h-3 w-3" />
Jump to Span in Trace
</button>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
import { LogsVisualizer } from "./LogsVisualizer"
export const TabsComponent = () => {
const { traces, logs, 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],
)
return (
<div className="flex flex-col gap-6">
<TelemetryFilters />
<Tabs.Root
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col gap-4"
>
<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><LogsVisualizer logs={logs} /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content
className="outline-none py-2"
value="tab7"
>
{activeTab === "tab7" && <ViewErrorBoundary><RumSessionsVisualizer /></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>
)
}