382 lines
14 KiB
TypeScript
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>
|
|
)
|
|
|
|
}
|