Files
go-otel/otel-ui/src/components/Tabs.tsx
T
developer 36f00d49b9
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m3s
CSP violation reports: ingest endpoint, ClickHouse storage, UI view
- POST /v1/csp collects browser CSP reports in both wire formats (legacy
  application/csp-report and Reporting API application/reports+json), with
  CORS limited to the operator's domains (any subdomain of k6n.net,
  tornberg.me, knatofs.se) and preflight handling.
- Reports are stored in a dedicated typed ClickHouse table (csp_reports,
  90-day TTL) with a JSONL file fallback (OTEL_CSP_PATH).
- New authenticated view endpoints /api/csp/reports and /api/csp/summary
  plus a 'CSP reports' screen in the UI (tab 12) with time window, site and
  directive filters and report drill-down.
- Documented in instrumentation.md; unit tests for parsing/handlers/file
  store and an env-gated ClickHouse integration test.
2026-09-23 16:50:43 +00:00

453 lines
17 KiB
TypeScript

import { useCallback, useEffect, useState } from "react"
import * as Tabs from "@radix-ui/react-tabs"
import {
Activity,
AlertTriangle,
Info,
CheckCircle2,
ChevronDown,
ChevronUp,
X,
} 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 { KeyValue } from "./common/DetailField"
import { TelemetryFilters } from "./TelemetryFilters"
import { PatternsVisualizer } from "./PatternsVisualizer"
import { RolloutsVisualizer } from "./RolloutsVisualizer"
import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
import { CspReportsVisualizer } from "./CspReportsVisualizer"
import { TodosVisualizer } from "./TodosVisualizer"
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 => (
<KeyValue key={attr.key} name={attr.key} value={formatAttributeValue(attr.value)} />
))}
</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 => (
<KeyValue key={attr.key} name={attr.key} value={formatAttributeValue(attr.value)} />
))}
</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)
const [statusNotice, setStatusNotice] = useState<{ text: string; undo?: () => void; error?: boolean } | null>(null)
// Trends tab only exists when the backend has ClickHouse-backed stats;
// null while the capability check is in flight.
const statsAvailable = useStatsAvailability()
// Triage action: apply optimistically, PATCH, then refresh so counts and
// badges match the server. A failure rolls back and says so; a success
// offers Undo because the card moves into the collapsed resolved/ignored
// group and would otherwise just vanish.
const handleIssueStatusChange = useCallback(
async (issue: Issue, status: string) => {
const previous = issue.status ?? "open"
setStatusBusy(true)
void issuesState.patch(issue.id, { status, regressedAt: undefined })
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}`)
const verb = status === "resolved" ? "Resolved" : status === "ignored" ? "Ignored (alerts muted)" : "Reopened"
setStatusNotice({
text: `${verb}: ${issue.title}`,
undo: status === "open" ? undefined : () => void handleIssueStatusChange({ ...issue, status }, previous),
})
} catch (err) {
console.error("Failed to update issue status:", err)
setStatusNotice({ text: `Couldn't update issue: ${err instanceof Error ? err.message : String(err)}`, error: true })
} finally {
await issuesState.refresh()
setStatusBusy(false)
}
},
[issuesState],
)
useEffect(() => {
if (!statusNotice) return
const t = setTimeout(() => setStatusNotice(null), statusNotice.error ? 8000 : 6000)
return () => clearTimeout(t)
}, [statusNotice])
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="tab10">
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content className="outline-none py-2" value="tab12">
{activeTab === "tab12" && <ViewErrorBoundary><CspReportsVisualizer /></ViewErrorBoundary>}
</Tabs.Content>
<Tabs.Content className="outline-none py-2" value="tab11">
{activeTab === "tab11" && <ViewErrorBoundary><TodosVisualizer /></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>
{statusNotice && (
<div
role="status"
className={`fixed bottom-10 left-1/2 z-50 flex max-w-[min(560px,calc(100vw-120px))] -translate-x-1/2 items-center gap-3 overflow-hidden rounded-2xl border bg-ds-surface/90 py-2.5 pl-2.5 pr-4 text-[13px] shadow-[0_8px_30px_-8px_rgba(0,0,0,0.45)] backdrop-blur-md animate-in fade-in slide-in-from-bottom-3 duration-300 ${
statusNotice.error ? "border-ds-critical/30" : "border-ds-ok/25"
}`}
>
<span
className={`pointer-events-none absolute inset-x-0 top-0 h-[2px] ${
statusNotice.error ? "bg-ds-critical/70" : "bg-ds-ok/70"
}`}
aria-hidden="true"
/>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
statusNotice.error ? "bg-ds-critical-bg text-ds-critical" : "bg-ds-ok/15 text-ds-ok"
}`}
aria-hidden="true"
>
{statusNotice.error ? <AlertTriangle className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
</span>
<span className={`truncate ${statusNotice.error ? "text-ds-critical" : "text-ds-text"}`}>
{statusNotice.text}
</span>
{statusNotice.undo && (
<button
type="button"
onClick={() => {
statusNotice.undo?.()
setStatusNotice(null)
}}
className="shrink-0 rounded-full border border-ds-accent/30 bg-ds-accent-bg px-2.5 py-1 font-semibold text-ds-accent transition-colors hover:bg-ds-accent hover:text-ds-on-accent"
>
Undo
</button>
)}
<button
type="button"
onClick={() => setStatusNotice(null)}
aria-label="Dismiss"
className="shrink-0 rounded-full p-1 text-ds-text-3 transition-colors hover:bg-ds-fill hover:text-ds-text"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
}