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,240 @@
|
||||
// OIDC authorization-code flow with PKCE against the go-auth service.
|
||||
// Client is public (no secret); tokens live in localStorage.
|
||||
|
||||
const ISSUER =
|
||||
(import.meta.env.VITE_OIDC_ISSUER as string | undefined) ??
|
||||
"https://users.k6n.net";
|
||||
const CLIENT_ID =
|
||||
(import.meta.env.VITE_OIDC_CLIENT_ID as string | undefined) ?? "otel-debug";
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
|
||||
const TOKEN_KEY = "otel.auth.tokens";
|
||||
|
||||
export interface Tokens {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
export interface UserClaims {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor() {
|
||||
super("Authentication required")
|
||||
this.name = "AuthRequiredError"
|
||||
}
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function readTokens(): Tokens | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = localStorage.getItem(TOKEN_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Tokens;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// tokens() returns tokens if the access token is currently valid.
|
||||
export function tokens(): Tokens | null {
|
||||
const t = readTokens();
|
||||
return t && t.expiresAt > Date.now() + 10_000 ? t : null;
|
||||
}
|
||||
|
||||
export function hasSession(): boolean {
|
||||
return readTokens() !== null;
|
||||
}
|
||||
|
||||
let refreshing: Promise<Tokens | null> | null = null;
|
||||
|
||||
// ensureTokens() returns valid tokens, refreshing if close to expiration.
|
||||
export async function ensureTokens(): Promise<Tokens | null> {
|
||||
const t = readTokens();
|
||||
if (!t) return null;
|
||||
if (t.expiresAt > Date.now() + 30_000) return t;
|
||||
if (!t.refreshToken) return null;
|
||||
|
||||
refreshing ??= doRefresh(t).finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function doRefresh(t: Tokens): Promise<Tokens | null> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: t.refreshToken!,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json()) as TokenResponse;
|
||||
saveTokens(body, t.refreshToken);
|
||||
return readTokens();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(
|
||||
TOKEN_KEY,
|
||||
JSON.stringify({
|
||||
accessToken: body.access_token,
|
||||
idToken: body.id_token,
|
||||
refreshToken: body.refresh_token ?? fallbackRefresh,
|
||||
expiresAt: Date.now() + body.expires_in * 1000,
|
||||
} satisfies Tokens),
|
||||
);
|
||||
}
|
||||
|
||||
export function currentUser(): UserClaims | null {
|
||||
const t = tokens();
|
||||
if (!t || !t.idToken) return null;
|
||||
try {
|
||||
const payload = t.idToken.split(".")[1];
|
||||
return JSON.parse(
|
||||
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
) as UserClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
const end = `${ISSUER}/oidc/v1/end_session`;
|
||||
window.location.assign(
|
||||
`${end}?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function redirectUri(): string {
|
||||
return `${window.location.origin}/auth_callback`;
|
||||
}
|
||||
|
||||
export async function login() {
|
||||
if (typeof window === "undefined") return;
|
||||
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const verifier = b64url(verifierBytes);
|
||||
const challenge = b64url(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
),
|
||||
),
|
||||
);
|
||||
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
|
||||
|
||||
sessionStorage.setItem(
|
||||
"otel.auth.pkce",
|
||||
JSON.stringify({
|
||||
verifier,
|
||||
state,
|
||||
back: window.location.pathname + window.location.search,
|
||||
}),
|
||||
);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
response_type: "code",
|
||||
scope: SCOPE,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
|
||||
window.location.assign(`${ISSUER}/oauth/v2/authorize?${params}`);
|
||||
}
|
||||
|
||||
export async function handleCallback(): Promise<string | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) return null;
|
||||
|
||||
const saved = JSON.parse(
|
||||
sessionStorage.getItem("otel.auth.pkce") ?? "null",
|
||||
) as { verifier: string; state: string; back: string } | null;
|
||||
sessionStorage.removeItem("otel.auth.pkce");
|
||||
|
||||
if (!saved || state !== saved.state) {
|
||||
throw new Error("OIDC state mismatch");
|
||||
}
|
||||
|
||||
const res = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
code,
|
||||
code_verifier: saved.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Token exchange failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
saveTokens((await res.json()) as TokenResponse);
|
||||
const destination = saved.back || "/";
|
||||
window.history.replaceState(null, "", destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
export async function authFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const t = await ensureTokens();
|
||||
// Protected API callers must never send anonymous requests. Apart from
|
||||
// avoiding unnecessary 401s, this prevents the RUM error reporter from
|
||||
// turning an expired session into an issues-refresh loop.
|
||||
if (!t?.accessToken) throw new AuthRequiredError()
|
||||
const headers = new Headers(init?.headers);
|
||||
if (t?.accessToken && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", `Bearer ${t.accessToken}`);
|
||||
}
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
export async function authSWRFetcher<T>(key: string): Promise<T> {
|
||||
const response = await authFetch(key)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Shared message-partitioning logic used by every ingestion path: the
|
||||
// initial history fetch, live WebSocket messages, and on-demand trace
|
||||
// fetches (GET /api/traces/{traceId}). Keeping this in one place means all
|
||||
// three stay consistent instead of drifting.
|
||||
import type { LogEntry, MetricData, OtelMessage, TraceData } from "./types"
|
||||
import { normalizeLogEntry, normalizeMetricData, normalizeTrace } from "./normalize"
|
||||
|
||||
export interface PartitionedMessages {
|
||||
traces: TraceData[]
|
||||
metrics: MetricData[]
|
||||
logs: LogEntry[]
|
||||
logsByTraceId: Record<string, LogEntry[]>
|
||||
traceIds: Set<string>
|
||||
}
|
||||
|
||||
export function partitionMessages(messages: OtelMessage[]): PartitionedMessages {
|
||||
const traces: TraceData[] = []
|
||||
const metrics: MetricData[] = []
|
||||
const logs: LogEntry[] = []
|
||||
const logsByTraceId: Record<string, LogEntry[]> = {}
|
||||
const traceIds = new Set<string>()
|
||||
|
||||
messages.forEach((message) => {
|
||||
switch (message.type) {
|
||||
case "traces": {
|
||||
const normalized = normalizeTrace(message.data)
|
||||
traces.push(normalized)
|
||||
normalized.resourceSpans.forEach((rs) =>
|
||||
rs.scopeSpans.forEach((ss) =>
|
||||
ss.spans.forEach((span) => {
|
||||
if (span.traceId) traceIds.add(span.traceId)
|
||||
}),
|
||||
),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "metrics":
|
||||
metrics.push(normalizeMetricData(message.data))
|
||||
break
|
||||
case "logs": {
|
||||
const logsWithId = (message.data.resourceLogs ?? []).map((log) =>
|
||||
normalizeLogEntry({
|
||||
...log,
|
||||
id: crypto.randomUUID(),
|
||||
scopeLogs: (log.scopeLogs ?? []).map((sl) => ({
|
||||
...sl,
|
||||
logRecords: (sl.logRecords ?? []).map((record) => ({
|
||||
...record,
|
||||
uid: record.uid ?? crypto.randomUUID(),
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
logsWithId.forEach((log) => {
|
||||
logs.push(log)
|
||||
log.scopeLogs.forEach((scopeLog) => {
|
||||
scopeLog.logRecords.forEach((record) => {
|
||||
if (record.traceId) {
|
||||
if (!logsByTraceId[record.traceId]) {
|
||||
logsByTraceId[record.traceId] = []
|
||||
}
|
||||
logsByTraceId[record.traceId].push(log)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { traces, metrics, logs, logsByTraceId, traceIds }
|
||||
}
|
||||
|
||||
export function mergeLogsByTraceId(
|
||||
prev: Record<string, LogEntry[]>,
|
||||
additions: Record<string, LogEntry[]>,
|
||||
): Record<string, LogEntry[]> {
|
||||
if (Object.keys(additions).length === 0) return prev
|
||||
const next = { ...prev }
|
||||
Object.entries(additions).forEach(([traceId, entries]) => {
|
||||
next[traceId] = [...(next[traceId] ?? []), ...entries]
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Helpers for presenting backend issues in the UI: trend sparkline data,
|
||||
// breadcrumb parsing and triage-state presentation.
|
||||
import type { Issue, IssueSample } from "./types"
|
||||
|
||||
export interface TrendPoint {
|
||||
hour: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an issue's sparse hourly trend map into a dense series covering
|
||||
* the full look-back window, oldest first - ready for a sparkline.
|
||||
*/
|
||||
export function trendPoints(issue: Issue, hours = 24): TrendPoint[] {
|
||||
const now = new Date()
|
||||
const currentHour = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours()),
|
||||
)
|
||||
const points: TrendPoint[] = []
|
||||
for (let i = hours - 1; i >= 0; i--) {
|
||||
const d = new Date(currentHour.getTime() - i * 3_600_000)
|
||||
const hour = d.toISOString().slice(0, 13) + ":00"
|
||||
points.push({ hour, count: issue.trend?.[hour] ?? 0 })
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/** True when the issue has at least one occurrence in the last hour. */
|
||||
export function isSpike(issue: Issue): boolean {
|
||||
const points = trendPoints(issue, 1)
|
||||
return (points[0]?.count ?? 0) > 1
|
||||
}
|
||||
|
||||
export interface ParsedBreadcrumb {
|
||||
t: number
|
||||
category: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
|
||||
export function parseBreadcrumbs(raw?: string): ParsedBreadcrumb[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(b): b is ParsedBreadcrumb =>
|
||||
b && typeof b === "object" && typeof b.category === "string" && typeof b.message === "string",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type IssueStatus = "open" | "resolved" | "ignored"
|
||||
|
||||
export const issueStatusMeta: Record<IssueStatus, { label: string; className: string }> = {
|
||||
open: { label: "Open", className: "bg-rose-500/10 text-rose-300 border-rose-500/20" },
|
||||
resolved: {
|
||||
label: "Resolved",
|
||||
className: "bg-emerald-500/10 text-emerald-300 border-emerald-500/20",
|
||||
},
|
||||
ignored: { label: "Ignored", className: "bg-slate-500/10 text-slate-400 border-white/10" },
|
||||
}
|
||||
|
||||
export function issueStatusOf(issue: Issue): IssueStatus {
|
||||
if (issue.status === "resolved" || issue.status === "ignored") return issue.status
|
||||
return "open"
|
||||
}
|
||||
|
||||
/** Most informative stacktrace across an issue's samples, if any. */
|
||||
export function latestStacktrace(issue: Issue): string | undefined {
|
||||
for (let i = issue.samples.length - 1; i >= 0; i--) {
|
||||
if (issue.samples[i]?.resolvedStacktrace) return issue.samples[i].resolvedStacktrace
|
||||
if (issue.samples[i]?.stacktrace) return issue.samples[i].stacktrace
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Latest sample carrying breadcrumbs (frontend issues), if any. */
|
||||
export function latestBreadcrumbs(issue: Issue): ParsedBreadcrumb[] {
|
||||
for (let i = issue.samples.length - 1; i >= 0; i--) {
|
||||
const parsed = parseBreadcrumbs(issue.samples[i]?.breadcrumbs)
|
||||
if (parsed.length) return parsed
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function sampleContext(sample: IssueSample): string[] {
|
||||
const parts: string[] = []
|
||||
if (sample.httpMethod) parts.push(sample.httpMethod)
|
||||
if (sample.httpRoute) parts.push(sample.httpRoute)
|
||||
if (sample.httpStatus) parts.push(String(sample.httpStatus))
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// OTLP JSON exporters omit fields when empty, so any of the arrays below can
|
||||
// be undefined at runtime even though the types say otherwise. Normalize every
|
||||
// message once at ingestion so consumers can rely on the declared types.
|
||||
import type { LogEntry, LogRecord, MetricData, TraceData } from "./types"
|
||||
|
||||
const attrList = (value: unknown): LogRecord["attributes"] =>
|
||||
Array.isArray(value) ? (value as LogRecord["attributes"]) : []
|
||||
|
||||
const normalizeLogRecord = (record: any): LogRecord => ({
|
||||
...record,
|
||||
attributes: attrList(record?.attributes),
|
||||
})
|
||||
|
||||
export const normalizeLogEntry = (log: any): LogEntry => ({
|
||||
...log,
|
||||
id: log?.id ?? crypto.randomUUID(),
|
||||
resource: {
|
||||
...log?.resource,
|
||||
attributes: attrList(log?.resource?.attributes),
|
||||
},
|
||||
scopeLogs: attrList(log?.scopeLogs).map((scopeLog: any) => ({
|
||||
...scopeLog,
|
||||
scope: scopeLog?.scope ?? { name: "unknown" },
|
||||
logRecords: attrList(scopeLog?.logRecords).map(normalizeLogRecord),
|
||||
})),
|
||||
})
|
||||
|
||||
export const normalizeTrace = (trace: any): TraceData => ({
|
||||
...trace,
|
||||
resourceSpans: attrList(trace?.resourceSpans).map((resourceSpan: any) => ({
|
||||
...resourceSpan,
|
||||
resource: {
|
||||
...resourceSpan?.resource,
|
||||
attributes: attrList(resourceSpan?.resource?.attributes),
|
||||
},
|
||||
scopeSpans: attrList(resourceSpan?.scopeSpans).map((scopeSpan: any) => ({
|
||||
...scopeSpan,
|
||||
scope: scopeSpan?.scope ?? { name: "unknown" },
|
||||
spans: attrList(scopeSpan?.spans).map((span: any) => ({
|
||||
...span,
|
||||
attributes: attrList(span?.attributes),
|
||||
events: attrList(span?.events),
|
||||
status: span?.status ?? {},
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
|
||||
export const normalizeMetricData = (data: any): MetricData => ({
|
||||
...data,
|
||||
resourceMetrics: attrList(data?.resourceMetrics).map((resourceMetric: any) => ({
|
||||
...resourceMetric,
|
||||
resource: {
|
||||
...resourceMetric?.resource,
|
||||
attributes: attrList(resourceMetric?.resource?.attributes),
|
||||
},
|
||||
scopeMetrics: attrList(resourceMetric?.scopeMetrics).map((scopeMetric: any) => ({
|
||||
...scopeMetric,
|
||||
scope: scopeMetric?.scope ?? { name: "unknown" },
|
||||
metrics: attrList(scopeMetric?.metrics).map((metric: any) => ({
|
||||
...metric,
|
||||
gauge: metric?.gauge
|
||||
? { ...metric.gauge, dataPoints: attrList(metric.gauge.dataPoints) }
|
||||
: undefined,
|
||||
sum: metric?.sum
|
||||
? { ...metric.sum, dataPoints: attrList(metric.sum.dataPoints) }
|
||||
: undefined,
|
||||
histogram: metric?.histogram
|
||||
? { ...metric.histogram, dataPoints: attrList(metric.histogram.dataPoints) }
|
||||
: undefined,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { AttributeValue, OtelAttribute } from "./types";
|
||||
|
||||
export function getAttributeValue(
|
||||
attributes: OtelAttribute[],
|
||||
key: string,
|
||||
): string {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const attr = attributes.find((a) => a.key === key || a.key.toLowerCase() === lowerKey);
|
||||
if (!attr) return "";
|
||||
|
||||
const { value } = attr;
|
||||
if (value.stringValue) return value.stringValue;
|
||||
if (value.intValue) return value.intValue;
|
||||
if (value.doubleValue !== undefined) return value.doubleValue.toString();
|
||||
if (value.boolValue !== undefined) return value.boolValue.toString();
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatTimestamp(nanoTimestamp: string): string {
|
||||
const ms = Number(nanoTimestamp) / 1_000_000;
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDuration(startNano: string, endNano: string): string {
|
||||
const durationMs = (Number(endNano) - Number(startNano)) / 1_000_000;
|
||||
|
||||
if (durationMs < 1) {
|
||||
return `${(durationMs * 1000).toFixed(2)}μs`;
|
||||
} else if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(2)}ms`;
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSpanKindLabel(kind: number): string {
|
||||
const kinds = [
|
||||
"UNSPECIFIED",
|
||||
"INTERNAL",
|
||||
"SERVER",
|
||||
"CLIENT",
|
||||
"PRODUCER",
|
||||
"CONSUMER",
|
||||
];
|
||||
return kinds[kind] || "UNKNOWN";
|
||||
}
|
||||
|
||||
export function getServiceName(
|
||||
resourceAttributes?: OtelAttribute[],
|
||||
recordAttributes?: OtelAttribute[],
|
||||
): string {
|
||||
const keys = ["service.name", "app.kubernetes.io/name", "app", "name"];
|
||||
if (resourceAttributes) {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(resourceAttributes, k);
|
||||
if (v && v !== "unknown" && v !== "unknown-service") return v;
|
||||
}
|
||||
}
|
||||
if (recordAttributes) {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(recordAttributes, k);
|
||||
if (v && v !== "unknown" && v !== "unknown-service") return v;
|
||||
}
|
||||
}
|
||||
return "unknown-service";
|
||||
}
|
||||
|
||||
export function isSpanError(span: {
|
||||
status?: { code?: number };
|
||||
attributes?: OtelAttribute[];
|
||||
}): boolean {
|
||||
if (span.status?.code === 2) return true;
|
||||
const statusStr =
|
||||
getAttributeValue(span.attributes ?? [], "http.response.status_code") ||
|
||||
getAttributeValue(span.attributes ?? [], "http.status_code");
|
||||
if (statusStr) {
|
||||
const code = parseInt(statusStr, 10);
|
||||
if (!isNaN(code) && code >= 400) return true;
|
||||
}
|
||||
const grpcStr = getAttributeValue(span.attributes ?? [], "rpc.grpc.status_code");
|
||||
if (grpcStr) {
|
||||
const code = parseInt(grpcStr, 10);
|
||||
if (!isNaN(code) && code > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPodName(
|
||||
resourceAttributes?: OtelAttribute[],
|
||||
pointAttributes?: OtelAttribute[],
|
||||
): string {
|
||||
const keys = [
|
||||
"k8s.pod.name",
|
||||
"pod.name",
|
||||
"pod",
|
||||
"pod_name",
|
||||
"k8s_pod_name",
|
||||
"service.instance.id",
|
||||
"service_instance_id",
|
||||
"instance.id",
|
||||
"k8s.pod.uid",
|
||||
"container.name",
|
||||
"container_name",
|
||||
"container.id",
|
||||
"host.name",
|
||||
"hostname",
|
||||
"host",
|
||||
"instance",
|
||||
"process.pid",
|
||||
];
|
||||
if (resourceAttributes) {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(resourceAttributes, k);
|
||||
if (v) return v;
|
||||
}
|
||||
}
|
||||
if (pointAttributes) {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(pointAttributes, k);
|
||||
if (v) return v;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const POD_COLORS = [
|
||||
{ stroke: "#38bdf8", fill: "#38bdf8", text: "text-sky-400", bg: "bg-sky-500/10", border: "border-sky-500/20" },
|
||||
{ stroke: "#a855f7", fill: "#a855f7", text: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20" },
|
||||
{ stroke: "#34d399", fill: "#34d399", text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||
{ stroke: "#fbbf24", fill: "#fbbf24", text: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||
{ stroke: "#fb7185", fill: "#fb7185", text: "text-rose-400", bg: "bg-rose-500/10", border: "border-rose-500/20" },
|
||||
{ stroke: "#22d3ee", fill: "#22d3ee", text: "text-cyan-400", bg: "bg-cyan-500/10", border: "border-cyan-500/20" },
|
||||
{ stroke: "#f472b6", fill: "#f472b6", text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
|
||||
{ stroke: "#818cf8", fill: "#818cf8", text: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
|
||||
];
|
||||
|
||||
export function getPodPalette(pod: string) {
|
||||
return POD_COLORS[hashString(pod) % POD_COLORS.length] ?? POD_COLORS[0];
|
||||
}
|
||||
|
||||
export const formatAttributeValue = (value: AttributeValue): string => {
|
||||
if (value.stringValue !== undefined) return value.stringValue;
|
||||
if (value.intValue !== undefined) return value.intValue.toString();
|
||||
if (value.doubleValue !== undefined) return value.doubleValue.toString();
|
||||
if (value.boolValue !== undefined) return value.boolValue.toString();
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const servicePalettes = [
|
||||
{
|
||||
gradient: "from-violet-600 via-indigo-600 to-sky-600",
|
||||
ring: "ring-violet-400/40",
|
||||
text: "text-violet-400",
|
||||
bg: "bg-violet-400/10",
|
||||
border: "border-violet-400/20",
|
||||
},
|
||||
{
|
||||
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
|
||||
ring: "ring-emerald-400/40",
|
||||
text: "text-emerald-400",
|
||||
bg: "bg-emerald-400/10",
|
||||
border: "border-emerald-400/20",
|
||||
},
|
||||
{
|
||||
gradient: "from-amber-600 via-orange-600 to-rose-600",
|
||||
ring: "ring-amber-400/40",
|
||||
text: "text-amber-400",
|
||||
bg: "bg-amber-400/10",
|
||||
border: "border-amber-400/20",
|
||||
},
|
||||
{
|
||||
gradient: "from-fuchsia-600 via-purple-600 to-indigo-700",
|
||||
ring: "ring-purple-400/40",
|
||||
text: "text-fuchsia-400",
|
||||
bg: "bg-fuchsia-400/10",
|
||||
border: "border-fuchsia-400/20",
|
||||
},
|
||||
{
|
||||
gradient: "from-sky-500 via-blue-600 to-indigo-700",
|
||||
ring: "ring-sky-400/40",
|
||||
text: "text-sky-400",
|
||||
bg: "bg-sky-400/10",
|
||||
border: "border-sky-400/20",
|
||||
},
|
||||
{
|
||||
gradient: "from-lime-500 via-emerald-600 to-teal-700",
|
||||
ring: "ring-lime-400/40",
|
||||
text: "text-lime-400",
|
||||
bg: "bg-lime-400/10",
|
||||
border: "border-lime-400/20",
|
||||
},
|
||||
];
|
||||
|
||||
export const hashString = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
export const getServicePalette = (service: string) => {
|
||||
const palette =
|
||||
servicePalettes[hashString(service) % servicePalettes.length] ??
|
||||
servicePalettes[0];
|
||||
return palette;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
|
||||
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
|
||||
// exactly the view you requested, and URLs are shareable/bookmarkable.
|
||||
// The Go server's SPA fallback serves index.html for all of these paths.
|
||||
export const TAB_PATHS: Record<string, string> = {
|
||||
"/issues": "tab0",
|
||||
"/patterns": "tab8",
|
||||
"/rollouts": "tab9",
|
||||
"/traces": "tab1",
|
||||
"/metrics": "tab2",
|
||||
"/logs": "tab3",
|
||||
"/setup": "tab4",
|
||||
"/trends": "tab5",
|
||||
"/search": "tab6",
|
||||
"/frontend": "tab7",
|
||||
"/rum": "tab7",
|
||||
}
|
||||
|
||||
|
||||
/** Tab value for a location pathname, or null when the path isn't a tab. */
|
||||
export function tabForPath(path: string): string | null {
|
||||
return TAB_PATHS[path] ?? null
|
||||
}
|
||||
|
||||
/** Canonical path for a tab value, or null when the tab has no route. */
|
||||
export function pathForTab(tab: string): string | null {
|
||||
for (const [path, value] of Object.entries(TAB_PATHS)) {
|
||||
if (value === tab) return path
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function formatDuration(nano: number): string {
|
||||
if (nano >= 3600000000000) { // 1 hour in nanoseconds
|
||||
return `${(nano / 3600000000000).toFixed(1)}h`;
|
||||
} else if (nano >= 60000000000) { // 1 minute
|
||||
return `${(nano / 60000000000).toFixed(1)}m`;
|
||||
} else if (nano >= 1000000000) { // 1 second
|
||||
return `${(nano / 1000000000).toFixed(1)}s`;
|
||||
} else if (nano >= 1000000) { // 1 millisecond
|
||||
return `${(nano / 1000000).toFixed(1)}ms`;
|
||||
} else if (nano >= 1000) { // 1 microsecond
|
||||
return `${(nano / 1000).toFixed(1)}μs`;
|
||||
} else {
|
||||
return `${nano.toFixed(1)}ns`;
|
||||
}
|
||||
}
|
||||
|
||||
/** "just now", "2m ago", "3h ago", "5d ago" */
|
||||
export function formatRelativeTime(msEpoch: number): string {
|
||||
const seconds = Math.max(0, (Date.now() - msEpoch) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// OpenTelemetry data types
|
||||
|
||||
export interface OtelAttribute {
|
||||
key: string;
|
||||
value: AttributeValue;
|
||||
}
|
||||
|
||||
export interface AttributeValue {
|
||||
stringValue?: string;
|
||||
intValue?: string;
|
||||
doubleValue?: number;
|
||||
boolValue?: boolean;
|
||||
}
|
||||
|
||||
export interface SpanEvent {
|
||||
name: string;
|
||||
timeUnixNano: string;
|
||||
attributes: OtelAttribute[];
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
kind: number;
|
||||
startTimeUnixNano: string;
|
||||
endTimeUnixNano: string;
|
||||
attributes: OtelAttribute[];
|
||||
events: SpanEvent[];
|
||||
status: Record<string, unknown>;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface ScopeSpan {
|
||||
scope: {
|
||||
name: string;
|
||||
};
|
||||
spans: Span[];
|
||||
}
|
||||
|
||||
export interface ResourceSpan {
|
||||
resource: {
|
||||
attributes: OtelAttribute[];
|
||||
};
|
||||
schemaUrl?: string;
|
||||
scopeSpans: ScopeSpan[];
|
||||
}
|
||||
|
||||
export interface TraceData {
|
||||
resourceSpans: ResourceSpan[];
|
||||
}
|
||||
|
||||
export interface MetricDataPoint {
|
||||
asInt?: string;
|
||||
asDouble?: number;
|
||||
attributes: OtelAttribute[];
|
||||
timeUnixNano?: string;
|
||||
pod?: string;
|
||||
// Histogram-specific fields
|
||||
bucketCounts?: string[];
|
||||
count?: string;
|
||||
exemplars?: Array<{
|
||||
asInt?: string;
|
||||
asDouble?: number;
|
||||
spanId: string;
|
||||
timeUnixNano: string;
|
||||
traceId: string;
|
||||
}>;
|
||||
explicitBounds?: number[];
|
||||
max?: number;
|
||||
min?: number;
|
||||
startTimeUnixNano?: string;
|
||||
sum?: number;
|
||||
}
|
||||
|
||||
export interface Metric {
|
||||
name: string;
|
||||
gauge?: {
|
||||
dataPoints: MetricDataPoint[];
|
||||
};
|
||||
sum?: {
|
||||
dataPoints: MetricDataPoint[];
|
||||
};
|
||||
histogram?: {
|
||||
dataPoints: MetricDataPoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScopeMetric {
|
||||
scope: {
|
||||
name: string;
|
||||
};
|
||||
metrics: Metric[];
|
||||
}
|
||||
|
||||
export interface ResourceMetric {
|
||||
resource: {
|
||||
attributes: OtelAttribute[];
|
||||
};
|
||||
scopeMetrics: ScopeMetric[];
|
||||
}
|
||||
|
||||
export interface MetricData {
|
||||
resourceMetrics: ResourceMetric[];
|
||||
}
|
||||
|
||||
export interface LogRecord {
|
||||
uid: string;
|
||||
timeUnixNano: string;
|
||||
body: {
|
||||
stringValue: string;
|
||||
};
|
||||
attributes: OtelAttribute[];
|
||||
severityNumber?: number;
|
||||
severityText?: string;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
}
|
||||
|
||||
export interface ScopeLog {
|
||||
scope: {
|
||||
name: string;
|
||||
};
|
||||
logRecords: LogRecord[];
|
||||
}
|
||||
|
||||
export interface ResourceLog {
|
||||
resource: {
|
||||
attributes: OtelAttribute[];
|
||||
};
|
||||
scopeLogs: ScopeLog[];
|
||||
}
|
||||
|
||||
export type LogEntry = ResourceLog & {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface LogData {
|
||||
resourceLogs: ResourceLog[];
|
||||
}
|
||||
|
||||
export interface OtelTraceData {
|
||||
type: "traces";
|
||||
|
||||
data: TraceData;
|
||||
}
|
||||
|
||||
export interface OtelMetricData {
|
||||
type: "metrics";
|
||||
|
||||
data: MetricData;
|
||||
}
|
||||
|
||||
export interface OtelLogData {
|
||||
type: "logs";
|
||||
|
||||
data: LogData;
|
||||
}
|
||||
|
||||
export type OtelHistoryMessage = OtelMessage & {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type OtelMessage = OtelTraceData | OtelMetricData | OtelLogData;
|
||||
|
||||
// Deduplicated errors derived server-side from traces/logs. See
|
||||
// GET /api/issues and GET /api/issues/{id} in the go-otel backend.
|
||||
export interface IssueSample {
|
||||
timestamp: number;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
stacktrace?: string;
|
||||
resolvedStacktrace?: string;
|
||||
httpMethod?: string;
|
||||
httpRoute?: string;
|
||||
httpStatus?: number;
|
||||
userAgent?: string;
|
||||
sourceIp?: string;
|
||||
release?: string;
|
||||
environment?: string;
|
||||
/** Raw JSON breadcrumb trail captured by otel-rum.js (frontend issues). */
|
||||
breadcrumbs?: string;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: "span_error" | "log_error" | "frontend_error" | string;
|
||||
service: string;
|
||||
count: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
affectedServices: string[];
|
||||
samples: IssueSample[];
|
||||
/** UTC hour-bucket ("2026-09-17T14:00") -> occurrence count, last 48h. */
|
||||
trend?: Record<string, number>;
|
||||
status?: "open" | "resolved" | "ignored" | string;
|
||||
resolvedAt?: number;
|
||||
normalizedTitle?: string;
|
||||
patternId?: string;
|
||||
patternSignature?: string;
|
||||
}
|
||||
|
||||
export interface SimilarIssue {
|
||||
issue: Issue;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
decision?: "same" | "different" | string;
|
||||
}
|
||||
|
||||
export interface IssuePattern {
|
||||
id: string;
|
||||
signature: string;
|
||||
service: string;
|
||||
kind: string;
|
||||
issueCount: number;
|
||||
occurrenceCount: number;
|
||||
lastSeen: number;
|
||||
issueIds: string[];
|
||||
}
|
||||
|
||||
export interface Rollout {
|
||||
id: string;
|
||||
namespace: string;
|
||||
workload: string;
|
||||
service?: string;
|
||||
release?: string;
|
||||
revision?: string;
|
||||
image?: string;
|
||||
phase: "progressing" | "completed" | "failed" | string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
lastSeen: number;
|
||||
generation: number;
|
||||
desiredReplicas: number;
|
||||
updatedReplicas: number;
|
||||
availableReplicas: number;
|
||||
readyReplicas: number;
|
||||
}
|
||||
|
||||
export interface RolloutImpact {
|
||||
configured: boolean;
|
||||
rollout: Rollout;
|
||||
points: Array<{ timestamp: number; value: string }>;
|
||||
}
|
||||
|
||||
// ---- Aggregate stats (ClickHouse-backed /api/stats/*) -------------------
|
||||
|
||||
export interface StatsPoint {
|
||||
/** ISO-ish bucket label from the server, e.g. "2026-09-17 14:00:00". */
|
||||
bucket: string;
|
||||
total: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface ErrorStatsResponse {
|
||||
granularity: "hour" | "day";
|
||||
points: StatsPoint[];
|
||||
}
|
||||
|
||||
export interface ReleaseStatsResponse {
|
||||
series: Record<string, StatsPoint[]>;
|
||||
}
|
||||
Reference in new issue
Block a user