From 21f04d696b08fb3279dee5dd02e96f0b32048ee8 Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 18 Sep 2026 20:51:37 +0000 Subject: [PATCH] more otel --- .gitignore | 7 +- Dockerfile | 13 + README.md | 159 +- agents_example.md | 21 +- build.sh | 28 +- cmd/server/main.go | 146 +- docs/ML.md | 145 + go.mod | 58 +- go.sum | 178 +- instrumentation.md | 175 + internal/auth/oidc.go | 380 ++ internal/auth/oidc_test.go | 165 + internal/collector/collector.go | 151 +- internal/collector/collector_test.go | 107 + internal/collector/handlers.go | 361 +- internal/collector/issue_extract.go | 343 +- internal/honeypot/honeypot.go | 245 + internal/honeypot/honeypot_test.go | 41 + internal/issues/tracker.go | 541 +- internal/issues/tracker_test.go | 154 + internal/kubernetes/types.go | 23 + internal/kubernetes/watcher.go | 239 + internal/prometheus/client.go | 103 + internal/sourcemaps/store.go | 106 + internal/storage/clickhouse.go | 528 ++ internal/storage/clickhouse_test.go | 121 + internal/storage/store.go | 12 + internal/websocket/server.go | 22 + k8s/clickhouse.yaml | 134 + k8s/deployment.yaml | 109 + k8s/ingress.yaml | 100 +- otel-ui/.gitignore | 24 + otel-ui/README.md | 73 + otel-ui/index.html | 20 + otel-ui/package-lock.json | 5917 +++++++++++++++++ otel-ui/package.json | 65 + otel-ui/postcss.config.mjs | 8 + otel-ui/public/vite.svg | 1 + otel-ui/src/App.tsx | 126 + otel-ui/src/assets/react.svg | 1 + otel-ui/src/components/CommandAndQueryBar.tsx | 251 + otel-ui/src/components/EntryVisualization.tsx | 9 + otel-ui/src/components/FrontendVisualizer.tsx | 1239 ++++ otel-ui/src/components/GaugeMeter.tsx | 372 ++ otel-ui/src/components/IssuesVisualizer.tsx | 601 ++ otel-ui/src/components/MetricsVisualizer.tsx | 1107 +++ otel-ui/src/components/PatternsVisualizer.tsx | 76 + otel-ui/src/components/RolloutsVisualizer.tsx | 48 + otel-ui/src/components/SearchAndWatcher.tsx | 504 ++ otel-ui/src/components/ServiceGraph.tsx | 495 ++ otel-ui/src/components/SetupPanel.tsx | 112 + otel-ui/src/components/Sidebar.tsx | 344 + .../src/components/StructuredLogTerminal.tsx | 582 ++ otel-ui/src/components/Tabs.tsx | 513 ++ otel-ui/src/components/TelemetryFilters.tsx | 124 + otel-ui/src/components/TopHeader.tsx | 232 + otel-ui/src/components/TraceVisualization.tsx | 1866 ++++++ otel-ui/src/components/TrendsVisualizer.tsx | 323 + otel-ui/src/components/ViewErrorBoundary.tsx | 62 + otel-ui/src/components/WaveformHistogram.tsx | 385 ++ .../contexts/ObservabilityContextProvider.tsx | 799 +++ .../src/contexts/ObservabilityContextValue.ts | 88 + otel-ui/src/hooks/use-mobile.ts | 19 + otel-ui/src/hooks/useIssues.ts | 50 + otel-ui/src/hooks/useLiveIssues.ts | 84 + otel-ui/src/hooks/useLogsForTraceId.ts | 7 + otel-ui/src/hooks/useMetrics.ts | 129 + otel-ui/src/hooks/useObservabilityData.ts | 14 + otel-ui/src/hooks/usePatterns.ts | 41 + otel-ui/src/hooks/useRollouts.ts | 14 + otel-ui/src/hooks/useStats.ts | 60 + otel-ui/src/index.css | 90 + otel-ui/src/lib/auth.ts | 240 + otel-ui/src/lib/ingest.ts | 85 + otel-ui/src/lib/issue-utils.ts | 95 + otel-ui/src/lib/normalize.ts | 74 + otel-ui/src/lib/otel-utils.ts | 209 + otel-ui/src/lib/routes.ts | 31 + otel-ui/src/lib/timeUtils.ts | 25 + otel-ui/src/lib/types.ts | 268 + otel-ui/src/main.tsx | 31 + otel-ui/src/styles/globals.css | 125 + otel-ui/tsconfig.app.json | 28 + otel-ui/tsconfig.json | 7 + otel-ui/tsconfig.node.json | 26 + otel-ui/vite.config.ts | 25 + scripts/upload-sourcemap.sh | 18 + static/otel-rum.js | 534 +- 88 files changed, 23419 insertions(+), 192 deletions(-) create mode 100644 docs/ML.md create mode 100644 instrumentation.md create mode 100644 internal/auth/oidc.go create mode 100644 internal/auth/oidc_test.go create mode 100644 internal/honeypot/honeypot.go create mode 100644 internal/honeypot/honeypot_test.go create mode 100644 internal/kubernetes/types.go create mode 100644 internal/kubernetes/watcher.go create mode 100644 internal/prometheus/client.go create mode 100644 internal/sourcemaps/store.go create mode 100644 internal/storage/clickhouse.go create mode 100644 internal/storage/clickhouse_test.go create mode 100644 k8s/clickhouse.yaml create mode 100644 otel-ui/.gitignore create mode 100644 otel-ui/README.md create mode 100644 otel-ui/index.html create mode 100644 otel-ui/package-lock.json create mode 100644 otel-ui/package.json create mode 100644 otel-ui/postcss.config.mjs create mode 100644 otel-ui/public/vite.svg create mode 100644 otel-ui/src/App.tsx create mode 100644 otel-ui/src/assets/react.svg create mode 100644 otel-ui/src/components/CommandAndQueryBar.tsx create mode 100644 otel-ui/src/components/EntryVisualization.tsx create mode 100644 otel-ui/src/components/FrontendVisualizer.tsx create mode 100644 otel-ui/src/components/GaugeMeter.tsx create mode 100644 otel-ui/src/components/IssuesVisualizer.tsx create mode 100644 otel-ui/src/components/MetricsVisualizer.tsx create mode 100644 otel-ui/src/components/PatternsVisualizer.tsx create mode 100644 otel-ui/src/components/RolloutsVisualizer.tsx create mode 100644 otel-ui/src/components/SearchAndWatcher.tsx create mode 100644 otel-ui/src/components/ServiceGraph.tsx create mode 100644 otel-ui/src/components/SetupPanel.tsx create mode 100644 otel-ui/src/components/Sidebar.tsx create mode 100644 otel-ui/src/components/StructuredLogTerminal.tsx create mode 100644 otel-ui/src/components/Tabs.tsx create mode 100644 otel-ui/src/components/TelemetryFilters.tsx create mode 100644 otel-ui/src/components/TopHeader.tsx create mode 100644 otel-ui/src/components/TraceVisualization.tsx create mode 100644 otel-ui/src/components/TrendsVisualizer.tsx create mode 100644 otel-ui/src/components/ViewErrorBoundary.tsx create mode 100644 otel-ui/src/components/WaveformHistogram.tsx create mode 100644 otel-ui/src/contexts/ObservabilityContextProvider.tsx create mode 100644 otel-ui/src/contexts/ObservabilityContextValue.ts create mode 100644 otel-ui/src/hooks/use-mobile.ts create mode 100644 otel-ui/src/hooks/useIssues.ts create mode 100644 otel-ui/src/hooks/useLiveIssues.ts create mode 100644 otel-ui/src/hooks/useLogsForTraceId.ts create mode 100644 otel-ui/src/hooks/useMetrics.ts create mode 100644 otel-ui/src/hooks/useObservabilityData.ts create mode 100644 otel-ui/src/hooks/usePatterns.ts create mode 100644 otel-ui/src/hooks/useRollouts.ts create mode 100644 otel-ui/src/hooks/useStats.ts create mode 100644 otel-ui/src/index.css create mode 100644 otel-ui/src/lib/auth.ts create mode 100644 otel-ui/src/lib/ingest.ts create mode 100644 otel-ui/src/lib/issue-utils.ts create mode 100644 otel-ui/src/lib/normalize.ts create mode 100644 otel-ui/src/lib/otel-utils.ts create mode 100644 otel-ui/src/lib/routes.ts create mode 100644 otel-ui/src/lib/timeUtils.ts create mode 100644 otel-ui/src/lib/types.ts create mode 100644 otel-ui/src/main.tsx create mode 100644 otel-ui/src/styles/globals.css create mode 100644 otel-ui/tsconfig.app.json create mode 100644 otel-ui/tsconfig.json create mode 100644 otel-ui/tsconfig.node.json create mode 100644 otel-ui/vite.config.ts create mode 100755 scripts/upload-sourcemap.sh diff --git a/.gitignore b/.gitignore index 1f6cee1..1f9fd1a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store otel-agent.log ./semgrep-results.json -# Separate frontend repo, checked out locally for the Dockerfile's UI build -# stage; not nested into this repo's history. -/otel-ui/ \ No newline at end of file +# Frontend dependencies and generated build output. +/otel-ui/node_modules/ +/otel-ui/dist/ +/otel-ui/dist-ssr/ diff --git a/Dockerfile b/Dockerfile index 6ea7c76..6e9cddc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,14 @@ +ARG APP_RELEASE=dev +ARG PUBLIC_SOURCEMAPS=false + # UI build stage ARG TARGETOS ARG TARGETARCH FROM node:alpine AS ui-builder +ARG APP_RELEASE +ENV VITE_APP_RELEASE=${APP_RELEASE} + WORKDIR /app COPY otel-ui/ . @@ -28,6 +34,8 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuff # Final stage FROM alpine:latest +ARG PUBLIC_SOURCEMAPS + RUN apk --no-cache add ca-certificates # Create non-root user @@ -39,6 +47,11 @@ COPY --from=go-builder /go-otel . COPY --from=go-builder /app/otel-ui/dist ./dist COPY static/ ./static COPY agents_example.md . +COPY instrumentation.md . + +# Private server-side maps are the default. Staging/dev can opt into public +# maps for Chrome DevTools with --build-arg PUBLIC_SOURCEMAPS=true. +RUN if [ "${PUBLIC_SOURCEMAPS}" != "true" ]; then find ./dist -type f -name '*.map' -delete; fi # Ensure the appuser owns the files RUN chown -R appuser:appgroup /home/appuser/ diff --git a/README.md b/README.md index c6ba935..98bdae3 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,26 @@ gRPC services for traces, metrics, and logs using standard OTLP protobuf definit - `GET /api/traces/{traceId}` - Retrieve all stored entries (spans and correlated logs) referencing a given trace id. -All endpoints above search the full persistent store (when `OTELLOG_PATH` -storage is enabled), not just the in-memory ring buffer. +All endpoints above search the full persistent store, not just the +in-memory ring buffer. + +### Stats API Endpoints (Port 8080, requires ClickHouse) + +Aggregate time-series over the stored telemetry, computed server-side from +an hourly rollup table. Available only when `OTEL_CLICKHOUSE_ADDR` is +configured (see Configuration below); otherwise they respond `501` with an +explanatory JSON body and the UI hides its "Trends" tab. + +- `GET /api/stats/errors?hours=72&service=` - Total/error entry counts per + hour (per day when `hours > 336`). Response: + `{"granularity":"hour","points":[{"bucket":"2026-09-17 14:00:00","total":42,"errors":3}]}`. +- `GET /api/stats/releases?hours=72&service=` - Per-release error-count + series: `{"series":{"v1.3":[{bucket,total,errors}...],"v1.4":[...]}}` + for release-over-release comparison. Entries need a `service.release` + attribute to appear here. +- `GET /api/stats/services` - Distinct services seen in the rollup. + +All accept `hours` (1..2160, default 72) and an optional `service` filter. ### Issues API Endpoints (Port 8080) @@ -115,6 +133,20 @@ Errors seen in traces (spans with an error status) and logs (severity Supports `limit`. - `GET /api/issues/{id}` - Fetch a single issue, including up to 5 recent sample occurrences (timestamp, trace/span id, message). +- `GET /api/patterns` - List deterministic groups of related exact issues. + Pattern grouping is explainable and does not merge issue IDs. +- `GET /api/issues/{id}/similar` - Return bounded, deterministic related-issue + suggestions with a score and matching reasons. +- `POST /api/issues/{id}/similar` - Record a review label with + `{"issueId":"...","decision":"same|different"}` for future training data. +- `GET /api/rollouts` - List read-only Kubernetes Deployment rollout state. +- `GET /api/rollouts/{id}` - Fetch one normalized rollout. +- `GET /api/rollouts/{id}/impact` - Return optional Prometheus error-rate points + around the rollout. +- `POST /api/sourcemaps` - Upload a JavaScript source map. Public multipart upload; + the release build script sends the source-map file contents directly. + multipart fields are `release`, `source` (the generated bundle URL/path), + and `file`. Uploaded maps are used server-side to resolve frontend stacks. ### Web Interface @@ -127,6 +159,9 @@ Errors seen in traces (spans with an error status) and logs (severity - `GET /agents_example.md` - Serves this repo's agent instructions doc directly, so an AI agent can fetch it from the base URL before making any API calls. +- `GET /instrumentation.md` - Serves the frontend/backend instrumentation + guide, including React error boundaries and source-map deployment. +- `GET /static/instrumentation.md` - Static/public alias for the same guide. ## Usage @@ -175,7 +210,12 @@ through the same history/search/issue pipeline as backend telemetry, tagged ```html ``` +React render failures should be reported from an error boundary as well. The +project UI uses this pattern in `otel-ui/src/components/ViewErrorBoundary.tsx`: + +```tsx +class AppBoundary extends React.Component<{ children: React.ReactNode }> { + componentDidCatch(error: Error, info: React.ErrorInfo) { + OtelRUM.last?.captureException(error, { + "exception.type": "ReactErrorBoundary", + "react.component_stack": info.componentStack, + }); + } + + render() { return this.props.children; } +} +``` + +The boundary should also render a fallback UI; otherwise a broken component +can leave the whole application blank while still being logged. + `fetch` is auto-instrumented by default (same-origin requests only) to inject `traceparent` and record request/response breadcrumbs; this can be disabled via `injectTraceHeaders: false` / `autoInstrumentFetch: false`. -Out of scope for v1: source map resolution (stack traces stay minified), -session replay, PII scrubbing - don't route sensitive data through -breadcrumbs or attributes. +The log ingest endpoint supports unauthenticated cross-origin POSTs; no OIDC +session or `Authorization` header is needed. +Source maps are kept server-side. When a captured frontend error has a +matching `service.release` and generated bundle path, the Issues view displays +the resolved original source frame while retaining the generated frame. + +#### Source-map deploy workflow + +Build the UI with a stable release identifier and keep the generated map next +to the bundle. Do not publish the map publicly: + +```sh +export RELEASE="2026.09.18-$(git rev-parse --short HEAD)" +npm --prefix otel-ui run build + +# Upload after the new UI is deployed. Use the generated URL/path exactly as +# it appears in browser stack traces. +curl -fS -X POST https://otel.example.com/api/sourcemaps \ + -F "release=$RELEASE" \ + -F "source=/assets/index-ABC123.js" \ + -F "file=@otel-ui/dist/assets/index-ABC123.js.map" +``` + +For this project, `otel-ui/src/main.tsx` sends `VITE_APP_RELEASE` as the RUM +`service.release` value; set it during the build, for example: + +```sh +VITE_APP_RELEASE="$RELEASE" npm --prefix otel-ui run build +``` + +The repository also includes `scripts/upload-sourcemap.sh`; after deploying, +run it against the emitted bundle, for example: + +```sh +RELEASE="$RELEASE" OTEL_URL="https://otel.example.com" \ + ./scripts/upload-sourcemap.sh otel-ui/dist/assets/index-ABC123.js +``` + +The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default +`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Upload maps for +each release before investigating its errors. The API never returns map +contents or original source files. + +`build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set, +it also builds the matching UI locally and uploads all generated JavaScript +maps. The Dockerfile removes `.map` files from the +final image by default; set `PUBLIC_SOURCEMAPS=true` for a development or +staging image where Chrome DevTools should fetch the original sources: + +```sh +PUBLIC_SOURCEMAPS=true ./build.sh +``` + +Public maps can expose application source code, so keep this disabled for +production unless that exposure is intentional. Credentials are never passed +to Docker or stored in the image. ## Configuration - `OTELLOG_PATH` - path to the persistent telemetry log file (default - `otel-agent.log`). + `otel-agent.log`). Only used when ClickHouse is not configured. +- `OTEL_SOURCEMAP_DIR` - directory for uploaded release source maps (default + `./sourcemaps`; mount persistent storage there in production). +- `OTEL_KUBERNETES_ENABLED` - set to `false` to disable rollout observation; + otherwise it activates in-cluster or `KUBECONFIG` observation when available. +- `OTEL_KUBERNETES_NAMESPACE` - optional namespace restriction; empty watches + all namespaces permitted by the service account. +- `OTEL_KUBERNETES_LABEL_SELECTOR` - optional Deployment label selector. +- `OTEL_PROMETHEUS_URL` - optional Prometheus base URL for rollout impact. +- `OTEL_PROMETHEUS_ERROR_QUERY` - optional `fmt.Sprintf`-style PromQL template; + `%q` placeholders receive service and release in that order. +- `OTEL_CLICKHOUSE_ADDR` - optional `host:port` (native protocol, usually + `9000`). When set, telemetry is stored in ClickHouse instead of the JSONL + file: history search stays fast and unbounded, and the aggregate stats + endpoints (`/api/stats/*`) and the UI "Trends" tab are enabled. The + schema (`otel` database, raw `entries` table + materialized hourly + rollup) is created automatically on first connect with a 90-day TTL; if + ClickHouse is unreachable at startup the server falls back to the file + store. +- `OTEL_CLICKHOUSE_DB` / `OTEL_CLICKHOUSE_USER` / + `OTEL_CLICKHOUSE_PASSWORD` - optional ClickHouse connection settings + (defaults: `otel` / `default` / empty). - `OTEL_INGEST_KEY` - optional shared key required (via `?key=` or the `X-Otel-Ingest-Key` header) on `/v1/traces`, `/v1/metrics` and `/v1/logs` over HTTP. Unset by default (no key required). This is meant to scope/rate-limit a public ingest endpoint fed by a browser script, not to provide real authentication - pair it with the built-in per-IP rate limiting on those endpoints if the endpoint is public. +- `OTEL_AGENT_TOKEN` - optional service token for non-interactive debugging + agents. Send it as `X-Otel-Agent-Token` or `Authorization: Bearer ...` when + calling protected `/api/history`, `/api/issues`, `/api/traces`, and stats + endpoints. Keep it in a Kubernetes Secret and never expose it to browsers. + +A single-node ClickHouse instance for Kubernetes is provided in +`k8s/clickhouse.yaml` (StatefulSet + Service + PVC, deployed in the +`monitoring` namespace); the receiver Deployment in `k8s/deployment.yaml` +already points at it. The UI's Trends tab (and live issue updates) appear +automatically once the stats endpoints answer; on file-store deployments the +tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends, +/setup) are served client-side by wouter and survive the OIDC login redirect. Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions. diff --git a/agents_example.md b/agents_example.md index f85c430..37d9ef2 100644 --- a/agents_example.md +++ b/agents_example.md @@ -4,6 +4,8 @@ This document provides instructions for AI agents on how to utilize the OTEL Col ## Base Configuration - **Base URL**: `https://otel.k6n.net` +- **Authentication**: set the service token in `OTEL_AGENT_TOKEN` and send it + as `X-Otel-Agent-Token: $OTEL_AGENT_TOKEN` on every protected debug request. - **Endpoints**: - `GET /api/history/search?q={query}&type={type}&since={ns}&until={ns}&limit={limit}`: Search all historical telemetry data. - **Default Limit**: 50 (max 100 recommended for context). @@ -16,8 +18,11 @@ This document provides instructions for AI agents on how to utilize the OTEL Col - **Ideal for Automation**: Use this after triggering an action to wait for the exact result. - **Rate limited**: capped per client IP; back off if you receive `429`. - `GET /api/traces/{traceId}`: Fetch every stored entry (spans and correlated logs) referencing a specific trace id in one call. Prefer this over substring-searching for a trace id. - - `GET /api/issues?limit={limit}`: List deduplicated error issues (from error-status spans and error/fatal-severity logs), most recently seen first. Use this to triage what's currently broken before diving into raw search. - - `GET /api/issues/{id}`: Fetch a single issue's detail, including sample occurrences with trace ids you can feed into `/api/traces/{traceId}`. + - `GET /api/issues`: List deduplicated error issues (from error-status spans and error/fatal-severity logs), most recently seen first. Supported filters are `limit` (default 100, max 200), `cursor`, `service`, `kind`, `status`, `q` (title substring), `sourceIp`, `route`, `since`, and `until` (the time bounds are unix nanoseconds). Results remain a JSON array for compatibility; when another page exists, use the `X-Next-Cursor` response header or the `Link: ...; rel=next` header in the next request. The cursor is opaque and must be reused unchanged. Use this to triage what's currently broken before diving into raw search. + - `GET /api/issues/{id}`: Fetch a single issue's detail, including sample occurrences with trace ids you can feed into `/api/traces/{traceId}`. `PATCH` the same path with `{"status":"resolved"|"ignored"|"open"}` to update triage state (a resolved issue that fires again is auto-reopened). + - `GET /api/stats/errors?hours=72&service=`: Hourly (daily when `hours > 336`) total/error entry counts, ClickHouse-backed. Responds `501` when the server runs on file storage. Use for "when did errors spike?" and trend questions. + - `GET /api/stats/releases?hours=72&service=`: Per-release error-count series (`{"series":{"v1.3":[...],"v1.4":[...]}}`) to compare error rates before/after a deploy. Only entries carrying a `service.release` attribute appear. + - `GET /api/stats/services`: Distinct services present in the stats rollup. - `GET /api/history`: Fetch recent history of all telemetry (searches the full persistent store, not just recent in-memory entries). - `GET /api/history/traces`: Fetch recent traces. - `GET /api/history/logs`: Fetch recent logs. @@ -31,7 +36,8 @@ When debugging an issue, start by searching for relevant keywords in the logs an **Example Request:** ```bash # Search for recent "ERROR" occurrences (default limit 50, latest first) -curl "https://otel.k6n.net/api/history/search?q=error" +curl "https://otel.k6n.net/api/history/search?q=error" \ + -H "X-Otel-Agent-Token: $OTEL_AGENT_TOKEN" ``` ### 2. Waiting for a Signal @@ -40,7 +46,8 @@ Use the `wait` endpoint to synchronize your debugging flow. This is perfect for **Example Request:** ```bash # Wait up to 60s for a specific traceId to show up in the collector -curl "https://otel.k6n.net/api/history/wait?q=5682138e4a9b4629852899d45e542456&timeout=60" +curl "https://otel.k6n.net/api/history/wait?q=5682138e4a9b4629852899d45e542456&timeout=60" \ + -H "X-Otel-Agent-Token: $OTEL_AGENT_TOKEN" ``` ### 3. Identifying Trace Chains @@ -49,7 +56,8 @@ If you find a log entry with a `traceId`, use the dedicated trace endpoint to fe **Example Request:** ```bash # Fetch all activity related to a specific trace -curl "https://otel.k6n.net/api/traces/5682138e4a9b4629852899d45e542456" +curl "https://otel.k6n.net/api/traces/5682138e4a9b4629852899d45e542456" \ + -H "X-Otel-Agent-Token: $OTEL_AGENT_TOKEN" ``` ### 3. Fetching Recent Telemetry @@ -57,7 +65,8 @@ To see the most recent activity without specific keywords, use the search endpoi **Example Request:** ```bash -curl "https://otel.k6n.net/api/history/search?limit=20" +curl "https://otel.k6n.net/api/history/search?limit=20" \ + -H "X-Otel-Agent-Token: $OTEL_AGENT_TOKEN" ``` ### 4. Data Format diff --git a/build.sh b/build.sh index 62366c6..3e7a1ef 100755 --- a/build.sh +++ b/build.sh @@ -1,8 +1,30 @@ #!/bin/bash +set -euo pipefail + +RELEASE="${RELEASE:-$(git rev-parse --short HEAD)}" +PUBLIC_SOURCEMAPS="${PUBLIC_SOURCEMAPS:-false}" + +docker build \ + --build-arg "APP_RELEASE=${RELEASE}" \ + --build-arg "PUBLIC_SOURCEMAPS=${PUBLIC_SOURCEMAPS}" \ + -t matst80/otel-debug:latest . -docker build -t matst80/otel-debug:latest . docker push matst80/otel-debug:latest - kubectl rollout restart deployment otel-debug-receiver -n monitoring +kubectl rollout status deployment/otel-debug-receiver -n monitoring -kubectl logs -f deployment/otel-debug-receiver -n monitoring \ No newline at end of file +# Docker builds intentionally omit public source maps. If the public receiver +# URL is present, build the same release locally and upload maps to the running +# receiver. The Kubernetes source-map volume is currently +# emptyDir, so upload after the rollout rather than before it. +if [[ -n "${OTEL_URL:-}" ]]; then + VITE_APP_RELEASE="$RELEASE" npm --prefix otel-ui run build + while IFS= read -r -d '' map; do + bundle="${map%.map}" + RELEASE="$RELEASE" OTEL_URL="$OTEL_URL" \ + ./scripts/upload-sourcemap.sh "$bundle" + done < <(find otel-ui/dist -type f -name '*.js.map' -print0) + find otel-ui/dist -type f -name '*.map' -delete +fi + +kubectl logs -f deployment/otel-debug-receiver -n monitoring diff --git a/cmd/server/main.go b/cmd/server/main.go index 52f8c54..7550016 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,7 +9,12 @@ import ( "os" "strings" + "git.tornberg.me/go-otel/internal/auth" "git.tornberg.me/go-otel/internal/collector" + "git.tornberg.me/go-otel/internal/honeypot" + kube "git.tornberg.me/go-otel/internal/kubernetes" + "git.tornberg.me/go-otel/internal/prometheus" + "git.tornberg.me/go-otel/internal/sourcemaps" "git.tornberg.me/go-otel/internal/storage" "git.tornberg.me/go-otel/internal/websocket" "go.opentelemetry.io/collector/pdata/plog" @@ -150,14 +155,20 @@ func (s *logsService) Export(ctx context.Context, req *logsgrpc.ExportLogsServic } // withCORS allows cross-origin ingestion (e.g. from the otel-rum.js browser -// script running on a third-party site) into the OTLP HTTP endpoints. It's -// deliberately permissive on origin, since this is a public ingest endpoint +// script running on a third-party site like event-agent.k6n.net) into the +// OTLP HTTP endpoints. It is deliberately permissive on origin and headers, // scoped by the ingest key/rate limiting instead of same-origin policy. func withCORS(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Otel-Ingest-Key") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + reqHeaders := r.Header.Get("Access-Control-Request-Headers") + if reqHeaders != "" { + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + } else { + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Otel-Ingest-Key, Authorization, Baggage, Traceparent, Tracestate, X-Requested-With") + } + w.Header().Set("Access-Control-Max-Age", "86400") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -166,20 +177,69 @@ func withCORS(next http.HandlerFunc) http.HandlerFunc { } } +// setupStore returns the telemetry store: ClickHouse when OTEL_CLICKHOUSE_ADDR +// is set (host:port, native protocol), otherwise the JSONL file store. +func setupStore(ctx context.Context) storage.Store { + addr := os.Getenv("OTEL_CLICKHOUSE_ADDR") + if addr == "" { + logPath := os.Getenv("OTELLOG_PATH") + if logPath == "" { + logPath = "otel-agent.log" + } + log.Printf("storage: file store at %s (set OTEL_CLICKHOUSE_ADDR for ClickHouse)", logPath) + return storage.NewFileStore(logPath) + } + + chStore, err := storage.NewClickHouseStore(ctx, storage.ClickHouseConfig{ + Addr: addr, + Database: os.Getenv("OTEL_CLICKHOUSE_DB"), + Username: os.Getenv("OTEL_CLICKHOUSE_USER"), + Password: os.Getenv("OTEL_CLICKHOUSE_PASSWORD"), + }) + if err != nil { + log.Printf("storage: ClickHouse unavailable (%v), falling back to file store", err) + logPath := os.Getenv("OTELLOG_PATH") + if logPath == "" { + logPath = "otel-agent.log" + } + return storage.NewFileStore(logPath) + } + log.Printf("storage: ClickHouse at %s", addr) + return chStore +} + func main() { // Configure history size (default 100, can be made configurable) historySize := 100 - logPath := os.Getenv("OTELLOG_PATH") - if logPath == "" { - logPath = "otel-agent.log" - } - store := storage.NewFileStore(logPath) + store := setupStore(context.Background()) coll := collector.New(nil, historySize, store) // Temporary nil for wsServer + kubeWatcher, kubeErr := kube.NewFromEnv(context.Background()) + if kubeErr != nil { + log.Printf("kubernetes: rollout watcher disabled: %v", kubeErr) + } else if kubeWatcher != nil { + coll.SetKubernetesWatcher(kubeWatcher) + log.Printf("kubernetes: rollout watcher enabled") + } + coll.SetPrometheusClient(prometheus.NewFromEnv()) + sourceMaps, err := sourcemaps.New(os.Getenv("OTEL_SOURCEMAP_DIR")) + if err != nil { + log.Fatalf("failed to initialize source maps: %v", err) + } + coll.SetSourceMaps(sourceMaps) + // Aggregate stats endpoints are enabled only for stores that support + // them (ClickHouse); they 501 politely on the file store. + if sq, ok := store.(storage.StatsQuerier); ok { + coll.SetStatsQuerier(sq) + } wsServer := websocket.New(coll) + honeypotTracker := honeypot.New(honeypot.ConfigFromEnv()) // Update collector with wsServer coll.SetBroadcaster(wsServer) - coll.SetIngestKey(os.Getenv("OTEL_INGEST_KEY")) + ingestKey := os.Getenv("OTEL_INGEST_KEY") + coll.SetIngestKey(ingestKey) + oidcAuth := auth.NewOidcAuth(ingestKey) + log.Printf("auth: OIDC issuer=%s client=%s disabled=%v", oidcAuth.Issuer(), oidcAuth.ClientID(), oidcAuth.IsDisabled()) // Start gRPC server go func() { @@ -205,8 +265,11 @@ func main() { // OTLP HTTP mux (standard OTel port 4318) otelMux := http.NewServeMux() otelMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces)) + otelMux.HandleFunc("/v1/traces/", withCORS(coll.HandleTraces)) otelMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics)) + otelMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics)) otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs)) + otelMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs)) otelMux.HandleFunc("/health", coll.HandleHealth) // UI and API mux (port 8080) @@ -215,19 +278,43 @@ func main() { // CORS is enabled here since this is the publicly exposed port (via the // ingress) that a browser script such as static/otel-rum.js posts to. uiMux.HandleFunc("/v1/traces", withCORS(coll.HandleTraces)) + uiMux.HandleFunc("/v1/traces/", withCORS(coll.HandleTraces)) uiMux.HandleFunc("/v1/metrics", withCORS(coll.HandleMetrics)) + uiMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics)) uiMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs)) + uiMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs)) uiMux.HandleFunc("/health", coll.HandleHealth) + uiMux.HandleFunc("GET /internal/honeypot/ips", honeypotTracker.HandleIPs) - uiMux.HandleFunc("/api/history", coll.HandleHistory) - uiMux.HandleFunc("/api/history/traces", coll.HandleHistoryByType("traces")) - uiMux.HandleFunc("/api/history/metrics", coll.HandleHistoryByType("metrics")) - uiMux.HandleFunc("/api/history/logs", coll.HandleHistoryByType("logs")) - uiMux.HandleFunc("/api/history/search", coll.HandleSearch) - uiMux.HandleFunc("/api/history/wait", coll.HandleWait) - uiMux.HandleFunc("GET /api/traces/{traceId}", coll.HandleTraceByID) - uiMux.HandleFunc("/api/issues", coll.HandleIssues) - uiMux.HandleFunc("GET /api/issues/{id}", coll.HandleIssueByID) + // Public OIDC endpoints + uiMux.HandleFunc("POST /api/auth/token", oidcAuth.HandleTokenExchange) + uiMux.HandleFunc("GET /api/auth/config", oidcAuth.HandleConfig) + uiMux.Handle("/api/honeypot/ips", oidcAuth.RequireAuth(http.HandlerFunc(honeypotTracker.HandleIPsForUser))) + // Source-map uploads are intentionally public so the release build script + // can upload maps without an OIDC session or bearer token. + uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap) + + // Protected API & WebSocket routes + uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory))) + uiMux.Handle("/api/history/traces", oidcAuth.RequireAuth(coll.HandleHistoryByType("traces"))) + uiMux.Handle("/api/history/metrics", oidcAuth.RequireAuth(coll.HandleHistoryByType("metrics"))) + uiMux.Handle("/api/history/logs", oidcAuth.RequireAuth(coll.HandleHistoryByType("logs"))) + uiMux.Handle("/api/history/search", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSearch))) + uiMux.Handle("/api/history/wait", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleWait))) + uiMux.Handle("GET /api/traces/{traceId}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceByID))) + uiMux.Handle("/api/issues", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssues))) + uiMux.Handle("GET /api/issues/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueByID))) + uiMux.Handle("GET /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSimilar))) + uiMux.Handle("POST /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSimilarityFeedback))) + uiMux.Handle("GET /api/patterns", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandlePatterns))) + uiMux.Handle("GET /api/rollouts", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRollouts))) + uiMux.Handle("GET /api/rollouts/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRolloutByID))) + uiMux.Handle("GET /api/rollouts/{id}/impact", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRolloutImpact))) + // Aggregate stats (ClickHouse-backed); return 501 with an explanatory + // JSON body when only the file store is configured. + uiMux.Handle("/api/stats/errors", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsErrors))) + uiMux.Handle("/api/stats/releases", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsReleases))) + uiMux.Handle("/api/stats/services", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleStatsServices))) uiMux.HandleFunc("/ws", wsServer.HandleConnections) uiMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) // Serve the agent instructions doc as a static route so an AI agent can @@ -235,7 +322,23 @@ func main() { uiMux.HandleFunc("GET /agents_example.md", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "agents_example.md") }) - uiMux.Handle("/", spaHandler(http.Dir("./dist"))) + uiMux.HandleFunc("GET /instrumentation.md", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "instrumentation.md") + }) + uiMux.HandleFunc("GET /static/instrumentation.md", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "instrumentation.md") + }) + distDir := "./dist" + if _, err := os.Stat(distDir); os.IsNotExist(err) { + if _, err := os.Stat("./otel-ui/dist"); err == nil { + distDir = "./otel-ui/dist" + } else if _, err := os.Stat("./index.html"); err == nil { + // Development fallback: serve the checked-in visualizer when no + // separately built frontend exists. + distDir = "." + } + } + uiMux.Handle("/", spaHandler(http.Dir(distDir))) log.Printf("Starting HTTP server on port 8080 (UI/API)") log.Printf("OTLP HTTP endpoints (also on port 8080):") @@ -254,7 +357,8 @@ func main() { } }() - if err := http.ListenAndServe(":8080", uiMux); err != nil { + uiHandler := honeypotTracker.Middleware(uiMux) + if err := http.ListenAndServe(":8080", uiHandler); err != nil { log.Fatalf("failed to start HTTP server: %v", err) } } diff --git a/docs/ML.md b/docs/ML.md new file mode 100644 index 0000000..9f01ae0 --- /dev/null +++ b/docs/ML.md @@ -0,0 +1,145 @@ +# Error detection model + +## Current decision + +Keep the original OTLP payloads as the source of truth, but derive a stable +feature row for each service/release/time window. The model should not consume +raw OTLP JSON, and it does not need to classify every span or log message into +a semantic taxonomy first. + +The first target is an **error-spike predictor**: + +> Given the telemetry observed for a service in a five-minute window, predict +> whether the following fifteen-minute window contains an abnormal error rate. + +This is more useful than classifying an event that the collector already knows +is an error. + +## Normalized event contract + +The extraction layer should reduce spans, logs, and metrics to these stable +dimensions before aggregation: + +| Field | Meaning | +| --- | --- | +| `window_start` | UTC five-minute bucket | +| `service` | `service.name`, or `unknown` | +| `release` | `service.release`, or empty | +| `environment` | deployment environment, when present | +| `kind` | `span`, `log`, or `metric` | +| `is_error` | explicit error status, error HTTP/gRPC status, or error severity | +| `status_code` | HTTP status when present | +| `severity_number` | OTLP log severity, when present | +| `duration_ms` | span duration, when present | +| `route` | normalized HTTP route, when present | +| `source` | `frontend` or `backend` | +| `trace_id` | correlation identifier, when present | +| `issue_fingerprint` | existing issue fingerprint, when available | + +PII-bearing fields such as raw messages, URLs, user agents, source IPs, and +stack traces must not be model features. They can remain in the raw history and +issue views subject to the existing retention and access controls. + +## First aggregate feature row + +For each service/release/window, derive: + +- total event count and error count +- error rate +- p50/p95 span duration +- counts by kind (`span`, `log`, `metric`) +- counts by HTTP status class (`2xx`, `4xx`, `5xx`) +- unique issue count and newly observed issue count +- frontend and backend error counts +- retry, timeout, and cancellation counts where attributes identify them +- release age, if deployment time is available + +The model input should be numeric and fixed-width. Categorical service and +release values can initially be filtered per service; one global model can be +considered after there is enough multi-service data. + +## Finding similar errors and spans + +Similarity should be a separate concern from the primary issue fingerprint. +Use deterministic grouping for the canonical issue ID because it is stable, +explainable, cheap at ingest time, and safe for alert counts. The current +`issues.NormalizeTitle` already removes common variable values such as UUIDs, +hex identifiers, and request numbers. + +The recommended matching stages are: + +1. **Canonical normalization**: lowercase and normalize IDs, numbers, quoted + values, URLs, and whitespace in messages and span names. +2. **Structured signature**: combine service, event kind, normalized route or + span name, exception type, HTTP/gRPC status class, and the top application + stack frames. This avoids merging unrelated errors that happen to contain + the same words. +3. **Exact fingerprint**: use the structured signature for the canonical issue + ID and deterministic deduplication. +4. **Near-match suggestions**: compare only within a bounded candidate group, + such as the same service and event kind. Token n-gram similarity, SimHash, + or weighted field similarity can suggest related issues without changing + their IDs. + +For example, these should usually be suggested as similar but remain separate +until confirmed: + +```text +checkout: database timeout after 2000ms +checkout: database timeout after 5000ms +``` + +They may represent the same underlying failure, but the timeout value could be +operationally important. A review UI can offer “merge” or “not related”; those +decisions become valuable labels later. + +Embeddings or a neural similarity model are reasonable as a second phase for +semantic discovery, especially when messages use different wording. They +should not be the first grouping mechanism: they add model/version drift, +latency, explainability, and possible sensitive-data leakage. If introduced, +embed redacted normalized text and stack signatures, retrieve nearest +neighbors, and present the result as a suggestion rather than silently +merging issues. + +For spans, the strongest similarity fields are usually the normalized span +name or route, service, status class, exception type, and stack-frame signature. +Trace IDs, span IDs, timestamps, and request-specific values should never +contribute to similarity. + +## Label + +For a row at time `t`, label it positive when the future interval `[t+5m, +t+20m)` satisfies both conditions: + +1. error rate is at least three times the trailing seven-day baseline for that + service and time-of-day; and +2. the future error count is at least 5. + +The count floor prevents a single error in a very low-volume service from +becoming an incident. The baseline must be calculated using data before `t` +only. + +## Baseline before neural network + +Start with two baselines: + +1. a trailing-rate/spike rule; and +2. logistic regression using the same aggregate features. + +Then compare a small MLP (`32 -> 16 -> 1`, ReLU plus sigmoid) against them. +Use a chronological train/validation/test split, and report precision, recall, +PR-AUC, and false alerts per service. Accuracy is not meaningful for this +imbalanced target. + +## Data readiness + +The current cluster ClickHouse instance has approximately 168k entries across +18 services, but only about 13 hours of history at the time this document was +created. It is sufficient to test normalization and aggregation, but not to +train the seven-day-baseline detector. Let the normalized feature table collect +at least two weeks of data before evaluating a model; four weeks is preferable. + +ClickHouse should remain a ClusterIP service while this work is developed. A +LoadBalancer would expose the receiver's OTLP and debug surfaces and is not +needed for model development; use a temporary `kubectl port-forward` or an +authenticated internal job for inspection and training data export. diff --git a/go.mod b/go.mod index 1110493..3194520 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module git.tornberg.me/go-otel go 1.27.0 require ( - github.com/gorilla/websocket v1.5.3 + github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + github.com/go-sourcemap/sourcemap v2.1.4+incompatible + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/natefinch/lumberjack v2.0.0+incompatible go.opentelemetry.io/collector/consumer v1.55.0 go.opentelemetry.io/collector/pdata v1.55.0 @@ -11,24 +13,70 @@ require ( golang.org/x/time v0.16.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 ) require ( github.com/BurntSushi/toml v1.6.0 // indirect + github.com/ClickHouse/ch-go v0.74.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/paulmach/orb v0.13.0 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/collector/featuregate v1.55.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 49c737d..0cca551 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,125 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= +github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/collector/consumer v1.55.0 h1:7Per8P4J0nlBrFVSXb+nwZ+egiel1BRtggZngyykGsM= @@ -48,16 +130,16 @@ go.opentelemetry.io/collector/internal/testutil v0.149.0 h1:OWfUPO3NFKSaJtz/SBZp go.opentelemetry.io/collector/internal/testutil v0.149.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= go.opentelemetry.io/collector/pdata v1.55.0 h1:WBgye8bo8koUyV9Vmp/r2Q3lgDezdsgfKDQAaM1oT2I= go.opentelemetry.io/collector/pdata v1.55.0/go.mod h1:6jPrbM4tuliCPACDznjFtxnnHisfKfzwrBVoeuESYuk= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/slim/otlp v1.10.0 h1:iR97Vs/ZDR+y9TfuP9b1XBtdPWeC+OMslIBmhcLU7jM= @@ -70,14 +152,49 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= @@ -89,9 +206,36 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/instrumentation.md b/instrumentation.md new file mode 100644 index 0000000..37a00b4 --- /dev/null +++ b/instrumentation.md @@ -0,0 +1,175 @@ +# Instrumentation guide + +This document describes the recommended instrumentation contract for this +receiver. It is available at `/instrumentation.md` so frontend and backend +agents can read it before integrating telemetry. + +## Shared identity + +Use the same values across all signals from one deployed application: + +- `service.name`: stable application name, for example `checkout-web`. +- `service.release`: immutable deploy identifier, for example + `2026.09.18-a1b2c3`. +- `service.environment`: `production`, `staging`, or `development`. + +The release must exactly match the source-map upload release. + +## Browser frontend + +Load the RUM script and initialize it once, before the application starts: + +```html + + +``` + +The collector captures uncaught errors, unhandled promise rejections, failed +HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate +frontend requests with backend spans when possible: + +```js +fetch("/api/orders", { headers: rum.traceHeaders() }); +``` + +For manually handled errors: + +```js +try { + riskyOperation(); +} catch (error) { + rum.captureException(error, { "feature.name": "checkout" }); +} +``` + +Do not put secrets, tokens, or sensitive customer data in breadcrumbs or +custom attributes. + +## React error boundaries + +Rendering errors are not reliably caught by global `window.onerror` handlers. +Report them from an error boundary and render a recovery screen: + +```tsx +import React from "react"; + +export class AppErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + window.OtelRUM?.last?.captureException(error, { + "exception.type": "ReactErrorBoundary", + "react.component_stack": info.componentStack, + }); + } + + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } +} +``` + +Use the same pattern in your application, including a user-visible fallback +for individual views and the application root. + +## Backend instrumentation + +Instrument backend services with an OpenTelemetry SDK or auto-instrumentation +and export OTLP to this receiver: + +- OTLP/gRPC: `otel.example.com:4317` +- OTLP/HTTP traces: `https://otel.example.com/v1/traces` +- OTLP/HTTP metrics: `https://otel.example.com/v1/metrics` +- OTLP/HTTP logs: `https://otel.example.com/v1/logs` + +Set the resource attributes on every provider/exporter: + +```text +service.name=checkout-api +service.release=2026.09.18-a1b2c3 +service.environment=production +``` + +Create spans around inbound requests, database calls, external HTTP calls, +queue operations, and important business operations. Record HTTP status, +route, method, and exception details. Mark failed spans with an error status. +The receiver derives backend issues from error spans and ERROR/FATAL logs. + +If HTTP ingest authentication is enabled, provide `OTEL_INGEST_KEY` as the +`X-Otel-Ingest-Key` header or `key` query parameter. Keep that key server-side; +browser clients should not receive it. + +For non-interactive agents that need to query protected debug endpoints, use a +separate `OTEL_AGENT_TOKEN` service token. Send it as +`X-Otel-Agent-Token: $OTEL_AGENT_TOKEN`; do not put it in browser code or +telemetry payloads. + +## Source maps + +Build the frontend with the same immutable release used by RUM. The command +and output directory depend on your build tool; the example below assumes a +standard `dist/` output: + +```sh +export RELEASE="2026.09.18-a1b2c3" +VITE_APP_RELEASE="$RELEASE" npm run build +``` + +After deployment, upload the contents of each generated JavaScript map to the +public multipart endpoint. No repository checkout or upload script is needed. +`source` must match the generated bundle URL/path captured in the browser +stack, while `file=@...` reads the local source-map file contents: + +```sh +curl --fail-with-body --silent --show-error \ + -X POST "https://otel.example.com/api/sourcemaps" \ + -F "release=$RELEASE" \ + -F "source=/assets/app-HASH.js" \ + -F "file=@dist/assets/app-HASH.js.map;type=application/json" +``` + +For example, if the deployed JavaScript is available at +`https://app.example.com/assets/main-abc123.js`, upload its matching map with +`source=/assets/main-abc123.js`. The source-map filename on the build machine +does not need to match the URL, but the `source` field must. + +Maps are parsed and retained server-side. Issue responses include a resolved +stack when `service.release` and the generated bundle path match. Original +source files and map contents are never returned by the API. + +The Docker build removes `.map` files from production images by default. For +development or staging, public maps can be enabled for Chrome DevTools: + +```sh +PUBLIC_SOURCEMAPS=true ./build.sh +``` + +Public maps expose application source code and should not be enabled in +production unless that is intentional. + +## Verification checklist + +1. Trigger a controlled frontend exception. +2. Trigger a controlled backend error span or ERROR log. +3. Confirm both appear under Issues as separate frontend/backend issues. +4. Confirm the issue sample contains the expected release. +5. Confirm a mapped frontend stack shows original file and line information. +6. Confirm trace IDs connect frontend requests to backend traces where headers + were propagated. diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..22cf757 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,380 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/big" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type contextKey string + +// UserKey is the context key for authenticated user claims. +const UserKey contextKey = "user" + +// Audience handles either a single string or an array of strings in JWT audience claim. +type Audience []string + +func (a *Audience) UnmarshalJSON(b []byte) error { + if len(b) > 0 && b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *a = Audience{s} + return nil + } + return json.Unmarshal(b, (*[]string)(a)) +} + +func (a Audience) Contains(s string) bool { + for _, v := range a { + if v == s { + return true + } + } + return false +} + +// IDClaims represents verified claims in an OIDC token. +type IDClaims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud Audience `json:"aud"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Roles []string `json:"roles,omitempty"` + Scope string `json:"scope,omitempty"` + Exp int64 `json:"exp"` +} + +type jwksDoc struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` +} + +// OidcAuth provides OIDC token verification and authentication middleware. +type OidcAuth struct { + issuer string + client string + disabled bool + ingestKey string + agentToken string + + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetched time.Time +} + +// NewOidcAuth constructs a new OidcAuth instance. +func NewOidcAuth(ingestKey string) *OidcAuth { + issuer := os.Getenv("OTEL_OIDC_ISSUER") + if issuer == "" { + issuer = "https://users.k6n.net" + } + client := os.Getenv("OTEL_OIDC_CLIENT_ID") + if client == "" { + client = "otel-debug" + } + disabled := os.Getenv("OTEL_AUTH_DISABLED") == "true" + + return &OidcAuth{ + issuer: strings.TrimSuffix(issuer, "/"), + client: client, + disabled: disabled, + ingestKey: ingestKey, + agentToken: os.Getenv("OTEL_AGENT_TOKEN"), + } +} + +func (a *OidcAuth) Issuer() string { + return a.issuer +} + +func (a *OidcAuth) ClientID() string { + return a.client +} + +func (a *OidcAuth) IsDisabled() bool { + return a.disabled +} + +func (a *OidcAuth) fetchKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) { + url := a.issuer + "/oidc/v1/keys" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jwks endpoint returned %d", resp.StatusCode) + } + + var doc jwksDoc + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, err + } + + keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) + for _, k := range doc.Keys { + if k.Kty != "RSA" { + continue + } + n, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil || len(eBytes) > 4 { + continue + } + var eInt uint32 + for _, b := range eBytes { + eInt = eInt<<8 | uint32(b) + } + keys[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(eInt)} + } + + if len(keys) == 0 { + return nil, errors.New("jwks contained no RSA keys") + } + return keys, nil +} + +func (a *OidcAuth) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if key, ok := a.keys[kid]; ok { + return key, nil + } + + // Throttle refreshes to at most once per minute + if a.keys == nil || time.Since(a.fetched) > time.Minute { + keys, err := a.fetchKeys(ctx) + if err != nil { + return nil, err + } + a.keys = keys + a.fetched = time.Now() + } + + key, ok := a.keys[kid] + if !ok { + return nil, fmt.Errorf("unknown key id %q", kid) + } + return key, nil +} + +// VerifyToken verifies an RS256 JWT against the cached JWKS public keys. +func (a *OidcAuth) VerifyToken(ctx context.Context, token string) (*IDClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("malformed token") + } + + var header struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + } + h, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("decode header: %w", err) + } + if err := json.Unmarshal(h, &header); err != nil { + return nil, fmt.Errorf("parse header: %w", err) + } + if header.Alg != "RS256" { + return nil, fmt.Errorf("unexpected alg %q", header.Alg) + } + + key, err := a.publicKey(ctx, header.Kid) + if err != nil { + return nil, err + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("decode signature: %w", err) + } + + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil { + return nil, errors.New("invalid signature") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode claims: %w", err) + } + + var claims IDClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("parse claims: %w", err) + } + + if claims.Iss != a.issuer { + return nil, fmt.Errorf("unexpected issuer %q", claims.Iss) + } + if !claims.Aud.Contains(a.client) { + return nil, errors.New("token not issued for this client") + } + if claims.Exp != 0 && time.Now().Unix() > claims.Exp { + return nil, errors.New("token expired") + } + + return &claims, nil +} + +// HandleTokenExchange proxies the OIDC token exchange endpoint so the SPA +// doesn't face CORS issues. +func (a *OidcAuth) HandleTokenExchange(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form body", http.StatusBadRequest) + return + } + + // Always ensure client_id is set + if r.PostForm.Get("client_id") == "" { + r.PostForm.Set("client_id", a.client) + } + + resp, err := http.PostForm(a.issuer+"/oauth/v2/token", r.PostForm) + if err != nil { + log.Printf("token endpoint unreachable: %v", err) + http.Error(w, "token endpoint unreachable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +// HandleConfig returns OIDC client configuration for the SPA frontend. +func (a *OidcAuth) HandleConfig(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": a.issuer, + "clientId": a.client, + "disabled": a.disabled, + }) +} + +// RequireAuth wraps an HTTP handler and ensures valid authentication. +func (a *OidcAuth) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.disabled { + next.ServeHTTP(w, r) + return + } + + // 1. Allow if upstream proxy (ingress) already verified via auth-url + if authUser := r.Header.Get("X-Auth-User"); authUser != "" { + ctx := context.WithValue(r.Context(), UserKey, &IDClaims{Sub: authUser, Email: authUser}) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // 2. Allow if valid ingest key is provided (for background automation / agents) + if a.ingestKey != "" { + k := r.Header.Get("X-Otel-Ingest-Key") + if k == "" { + k = r.URL.Query().Get("key") + } + if k != "" && k == a.ingestKey { + next.ServeHTTP(w, r) + return + } + } + + // Service-to-service credential for debugging agents. It is separate + // from the ingest key because it grants access to protected debug data. + if a.agentToken != "" && constantTimeTokenMatch(r, a.agentToken) { + ctx := context.WithValue(r.Context(), UserKey, &IDClaims{Sub: "service-agent", Name: "debug agent"}) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // 3. Check Bearer token (or access_token query param for WebSocket) + token := bearerToken(r) + if token != "" { + claims, err := a.VerifyToken(r.Context(), token) + if err == nil { + ctx := context.WithValue(r.Context(), UserKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid token: " + err.Error(), + }) + return + } + + // 4. Unauthorized + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="%s"`, a.client)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "authentication required", + }) + }) +} + +func constantTimeTokenMatch(r *http.Request, expected string) bool { + token := r.Header.Get("X-Otel-Agent-Token") + if token == "" { + auth := r.Header.Get("Authorization") + if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") { + token = strings.TrimSpace(auth[7:]) + } + } + return len(token) == len(expected) && subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1 +} + +func bearerToken(r *http.Request) string { + if t := r.URL.Query().Get("access_token"); t != "" { + return t + } + auth := r.Header.Get("Authorization") + if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") { + return strings.TrimSpace(auth[7:]) + } + return "" +} + +// CurrentUser retrieves the authenticated user claims from the context, if present. +func CurrentUser(ctx context.Context) *IDClaims { + claims, _ := ctx.Value(UserKey).(*IDClaims) + return claims +} diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go new file mode 100644 index 0000000..907cfba --- /dev/null +++ b/internal/auth/oidc_test.go @@ -0,0 +1,165 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAudienceUnmarshal(t *testing.T) { + var a1 Audience + if err := json.Unmarshal([]byte(`"single-aud"`), &a1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !a1.Contains("single-aud") || a1.Contains("other") { + t.Fatalf("unexpected audience content: %v", a1) + } + + var a2 Audience + if err := json.Unmarshal([]byte(`["aud1", "aud2"]`), &a2); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !a2.Contains("aud1") || !a2.Contains("aud2") || a2.Contains("aud3") { + t.Fatalf("unexpected audience content: %v", a2) + } +} + +func TestVerifyToken(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate RSA key: %v", err) + } + + auth := &OidcAuth{ + issuer: "https://test.issuer", + client: "test-client", + keys: map[string]*rsa.PublicKey{ + "key-1": &privKey.PublicKey, + }, + fetched: time.Now(), + } + + // Helper to mint test token + mintToken := func(kid string, claims IDClaims) string { + headerJSON, _ := json.Marshal(map[string]string{ + "kid": kid, + "alg": "RS256", + }) + claimsJSON, _ := json.Marshal(claims) + + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + sum := sha256.Sum256([]byte(headerB64 + "." + payloadB64)) + sig, _ := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, sum[:]) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + + return headerB64 + "." + payloadB64 + "." + sigB64 + } + + // Valid token + validToken := mintToken("key-1", IDClaims{ + Iss: "https://test.issuer", + Sub: "user-123", + Aud: Audience{"test-client"}, + Exp: time.Now().Add(time.Hour).Unix(), + }) + + claims, err := auth.VerifyToken(context.Background(), validToken) + if err != nil { + t.Fatalf("expected valid token to pass, got: %v", err) + } + if claims.Sub != "user-123" { + t.Fatalf("expected sub user-123, got %s", claims.Sub) + } + + // Expired token + expiredToken := mintToken("key-1", IDClaims{ + Iss: "https://test.issuer", + Sub: "user-123", + Aud: Audience{"test-client"}, + Exp: time.Now().Add(-time.Hour).Unix(), + }) + if _, err := auth.VerifyToken(context.Background(), expiredToken); err == nil { + t.Fatalf("expected expired token to fail") + } + + // Wrong audience + wrongAudToken := mintToken("key-1", IDClaims{ + Iss: "https://test.issuer", + Sub: "user-123", + Aud: Audience{"other-client"}, + Exp: time.Now().Add(time.Hour).Unix(), + }) + if _, err := auth.VerifyToken(context.Background(), wrongAudToken); err == nil { + t.Fatalf("expected wrong audience to fail") + } + + // Wrong issuer + wrongIssToken := mintToken("key-1", IDClaims{ + Iss: "https://wrong.issuer", + Sub: "user-123", + Aud: Audience{"test-client"}, + Exp: time.Now().Add(time.Hour).Unix(), + }) + if _, err := auth.VerifyToken(context.Background(), wrongIssToken); err == nil { + t.Fatalf("expected wrong issuer to fail") + } +} + +func TestRequireAuthMiddleware(t *testing.T) { + auth := &OidcAuth{ + issuer: "https://test.issuer", + client: "test-client", + ingestKey: "secret-key", + agentToken: "agent-secret", + } + + handler := auth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + // 1. Unauthenticated request -> 401 + req := httptest.NewRequest("GET", "/api/history", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unauthenticated request, got %d", rec.Code) + } + + // 2. Ingest key in header -> 200 + reqKey := httptest.NewRequest("GET", "/api/history", nil) + reqKey.Header.Set("X-Otel-Ingest-Key", "secret-key") + recKey := httptest.NewRecorder() + handler.ServeHTTP(recKey, reqKey) + if recKey.Code != http.StatusOK { + t.Fatalf("expected 200 with valid ingest key, got %d", recKey.Code) + } + + // 3. Dedicated service token in a header -> 200 + reqAgent := httptest.NewRequest("GET", "/api/history", nil) + reqAgent.Header.Set("X-Otel-Agent-Token", "agent-secret") + recAgent := httptest.NewRecorder() + handler.ServeHTTP(recAgent, reqAgent) + if recAgent.Code != http.StatusOK { + t.Fatalf("expected 200 with valid agent token, got %d", recAgent.Code) + } + + // 4. X-Auth-User from upstream ingress -> 200 + reqAuthUser := httptest.NewRequest("GET", "/api/history", nil) + reqAuthUser.Header.Set("X-Auth-User", "mats@tornberg.me") + recAuthUser := httptest.NewRecorder() + handler.ServeHTTP(recAuthUser, reqAuthUser) + if recAuthUser.Code != http.StatusOK { + t.Fatalf("expected 200 with X-Auth-User, got %d", recAuthUser.Code) + } +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go index af532b6..b81f8ab 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -7,11 +7,16 @@ import ( "context" "encoding/json" "log" + "os" + "strconv" "strings" "sync" "time" "git.tornberg.me/go-otel/internal/issues" + kube "git.tornberg.me/go-otel/internal/kubernetes" + "git.tornberg.me/go-otel/internal/prometheus" + "git.tornberg.me/go-otel/internal/sourcemaps" "git.tornberg.me/go-otel/internal/storage" "git.tornberg.me/go-otel/internal/websocket" @@ -28,6 +33,7 @@ type Collector struct { history *ring.Ring historyMutex sync.RWMutex store storage.Store + storeChan chan websocket.HistoryEntry subscribers map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool subMutex sync.Mutex waitLimiter *ipRateLimiter @@ -35,15 +41,26 @@ type Collector struct { ingestLimiter *ipRateLimiter ingestKey string + + notifyIssuesFn func() + + // statsQuerier is the ClickHouse store when configured; nil otherwise. + // The stats endpoints return 501 when it's nil so the UI can hide those + // views instead of erroring. + statsQuerier storage.StatsQuerier + sourceMaps *sourcemaps.Resolver + kubeWatcher *kube.Watcher + prometheus *prometheus.Client } // New creates a new Collector instance. func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store) *Collector { - return &Collector{ + c := &Collector{ broadcaster: broadcaster, historySize: historySize, history: ring.New(historySize), store: store, + storeChan: make(chan websocket.HistoryEntry, 10000), subscribers: make(map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool), // Allow bursts of a few immediate waits, then steady-state ~1 req/sec per IP. waitLimiter: newIPRateLimiter(1, 5), @@ -53,6 +70,26 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store // looping) from hammering the collector. ingestLimiter: newIPRateLimiter(20, 50), } + + if store != nil { + // Bounded worker pool prevents concurrent ClickHouse INSERTs from + // multiplying query and merge memory under bursty ingest traffic. + numWorkers := 4 + if value, err := strconv.Atoi(os.Getenv("OTEL_STORE_WORKERS")); err == nil && value > 0 && value <= 32 { + numWorkers = value + } + for i := 0; i < numWorkers; i++ { + go func() { + for entry := range c.storeChan { + if err := c.store.Append(context.Background(), entry); err != nil { + log.Printf("failed to append to store: %v", err) + } + } + }() + } + } + + return c } // SetIngestKey configures an optional shared key that HTTP OTLP ingestion @@ -64,19 +101,102 @@ func (c *Collector) SetIngestKey(key string) { c.ingestKey = key } +// SetStatsQuerier enables the aggregate stats endpoints (error trends, +// release comparisons) when a store supporting them is configured. +func (c *Collector) SetStatsQuerier(q storage.StatsQuerier) { + c.statsQuerier = q +} + +func (c *Collector) SetKubernetesWatcher(w *kube.Watcher) { c.kubeWatcher = w } +func (c *Collector) SetPrometheusClient(p *prometheus.Client) { c.prometheus = p } +func (c *Collector) ListRollouts() []kube.Rollout { + if c.kubeWatcher == nil { + return nil + } + return c.kubeWatcher.List() +} +func (c *Collector) GetRollout(id string) (kube.Rollout, bool) { + if c.kubeWatcher == nil { + return kube.Rollout{}, false + } + return c.kubeWatcher.Get(id) +} +func (c *Collector) KubernetesEnabled() bool { return c.kubeWatcher != nil && c.kubeWatcher.Enabled() } +func (c *Collector) KubernetesError() string { + if c.kubeWatcher == nil { + return "kubernetes integration is not configured" + } + return c.kubeWatcher.Error() +} + // ListIssues returns deduplicated error issues, most recently seen first. func (c *Collector) ListIssues(limit int) []issues.Issue { - return c.issues.List(limit) + list := c.issues.ListFiltered(issues.ListOptions{Limit: limit}).Issues + if c.sourceMaps != nil { + for i := range list { + resolveIssue(&list[i], c.sourceMaps) + } + } + return list +} + +// ListIssuesFiltered returns a filtered page of deduplicated issues. +func (c *Collector) ListIssuesFiltered(opts issues.ListOptions) issues.ListPage { + page := c.issues.ListFiltered(opts) + if c.sourceMaps != nil { + for i := range page.Issues { + resolveIssue(&page.Issues[i], c.sourceMaps) + } + } + return page } // GetIssue returns a single issue by id. func (c *Collector) GetIssue(id string) (issues.Issue, bool) { - return c.issues.Get(id) + issue, ok := c.issues.Get(id) + if ok && c.sourceMaps != nil { + resolveIssue(&issue, c.sourceMaps) + } + return issue, ok +} + +func (c *Collector) SetSourceMaps(resolver *sourcemaps.Resolver) { c.sourceMaps = resolver } + +func resolveIssue(issue *issues.Issue, resolver *sourcemaps.Resolver) { + for i := range issue.Samples { + issue.Samples[i].ResolvedStacktrace = resolver.ResolveStack(issue.Samples[i].Release, issue.Samples[i].Stacktrace) + } +} + +// SetIssueStatus updates the triage state of an issue. +func (c *Collector) SetIssueStatus(id, status string) (issues.Issue, bool) { + return c.issues.SetStatus(id, status) +} + +func (c *Collector) SimilarIssues(id string, limit int) ([]issues.SimilarIssue, bool) { + return c.issues.Similar(id, limit) +} + +func (c *Collector) ListPatterns() []issues.Pattern { return c.issues.Patterns() } + +func (c *Collector) SetSimilarityFeedback(a, b, decision string) bool { + return c.issues.SetSimilarityFeedback(a, b, decision) } // SetBroadcaster updates the broadcaster used by the collector. func (c *Collector) SetBroadcaster(broadcaster websocket.Broadcaster) { c.broadcaster = broadcaster + if notifier, ok := broadcaster.(interface{ NotifyIssues() }); ok { + c.notifyIssuesFn = notifier.NotifyIssues + } +} + +// notifyIssues pings WebSocket clients that new error telemetry arrived, so +// the UI can refresh its Issues view without waiting for its poll interval. +func (c *Collector) notifyIssues() { + if c.notifyIssuesFn != nil { + c.notifyIssuesFn() + } } func (c *Collector) addToHistory(dataType websocket.DataType, data json.RawMessage) { @@ -92,12 +212,11 @@ func (c *Collector) addToHistory(dataType websocket.DataType, data json.RawMessa c.historyMutex.Unlock() if c.store != nil { - // Asynchronously append to store to avoid blocking ingestion - go func() { - if err := c.store.Append(context.Background(), entry); err != nil { - log.Printf("failed to append to store: %v", err) - } - }() + select { + case c.storeChan <- entry: + default: + log.Printf("collector: store write queue full, dropping entry to preserve ingestion") + } } c.notifySubscribers(entry) @@ -219,8 +338,10 @@ func (c *Collector) ConsumeTraces(ctx context.Context, td ptrace.Traces) error { } // Store and broadcast raw bytes as json.RawMessage to avoid unsafe interface{} usage - c.addToHistory(websocket.DataTypeTraces, buf) - extractSpanErrors(c.issues, td, time.Now().UnixNano()) + c.addToHistory(websocket.DataTypeTraces, buf) + if extractSpanErrors(c.issues, td, time.Now().UnixNano()) { + c.notifyIssues() + } entry := websocket.Entry{ Type: string(websocket.DataTypeTraces), @@ -241,7 +362,7 @@ func (c *Collector) ConsumeMetrics(ctx context.Context, md pmetric.Metrics) erro } // Store and broadcast raw bytes as json.RawMessage to avoid unsafe interface{} usage - c.addToHistory(websocket.DataTypeMetrics, buf) + c.addToHistory(websocket.DataTypeMetrics, buf) entry := websocket.Entry{ Type: string(websocket.DataTypeMetrics), @@ -262,8 +383,10 @@ func (c *Collector) ConsumeLogs(ctx context.Context, ld plog.Logs) error { } // Store and broadcast raw bytes as json.RawMessage to avoid unsafe interface{} usage - c.addToHistory(websocket.DataTypeLogs, buf) - extractLogErrors(c.issues, ld, time.Now().UnixNano()) + c.addToHistory(websocket.DataTypeLogs, buf) + if extractLogErrors(c.issues, ld, time.Now().UnixNano()) { + c.notifyIssues() + } entry := websocket.Entry{ Type: string(websocket.DataTypeLogs), diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 08d710d..8bbf3ab 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "git.tornberg.me/go-otel/internal/issues" "git.tornberg.me/go-otel/internal/storage" "git.tornberg.me/go-otel/internal/websocket" "go.opentelemetry.io/collector/pdata/pcommon" @@ -242,6 +243,77 @@ func TestConsumeTracesRecordsIssue(t *testing.T) { } } +func TestConsumeTracesHTTPErrorsAndNaming(t *testing.T) { + coll := New(noopBroadcaster{}, 10, nil) + + td := ptrace.NewTraces() + + // 1. Nginx span with 404 and empty name (common for ingress/reverse proxy) + rs1 := td.ResourceSpans().AppendEmpty() + rs1.Resource().Attributes().PutStr("service.name", "nginx") + s1 := rs1.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + s1.SetName("") // empty name + s1.Attributes().PutInt("http.status_code", 404) + s1.Attributes().PutStr("http.method", "GET") + s1.Attributes().PutStr("http.target", "/missing-path") + + // 2. e2e-verify span with 500 and service name in span attributes + rs2 := td.ResourceSpans().AppendEmpty() + // Resource has no service.name, fallback to app attribute on span + s2 := rs2.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + s2.SetName("test-endpoint") + s2.Attributes().PutStr("app.kubernetes.io/name", "e2e-verify") + s2.Attributes().PutInt("http.response.status_code", 500) + s2.Attributes().PutStr("http.request.method", "POST") + s2.Attributes().PutStr("http.route", "/api/v1/test") + + if err := coll.ConsumeTraces(context.Background(), td); err != nil { + t.Fatalf("ConsumeTraces failed: %v", err) + } + + issuesList := coll.ListIssues(10) + if len(issuesList) != 2 { + t.Fatalf("expected 2 distinct issues (404 and 500), got %d: %+v", len(issuesList), issuesList) + } + + foundNginx := false + foundE2E := false + + for _, iss := range issuesList { + if iss.Title == "(error)" { + t.Errorf("issue title must not be '(error)', got %q", iss.Title) + } + if iss.Service == "unknown" { + t.Errorf("service must not be 'unknown', got %s", iss.Service) + } + if iss.Service == "nginx" { + foundNginx = true + if !strings.Contains(iss.Title, "HTTP 404 (Not Found): GET /missing-path") { + t.Errorf("expected nginx 404 title, got %q", iss.Title) + } + if iss.Samples[0].HTTPStatus != 404 { + t.Errorf("expected sample HTTPStatus 404, got %d", iss.Samples[0].HTTPStatus) + } + } + if iss.Service == "e2e-verify" { + foundE2E = true + if !strings.Contains(iss.Title, "HTTP 500 (Internal Server Error): POST /api/v1/test") { + t.Errorf("expected e2e-verify 500 title, got %q", iss.Title) + } + if iss.Samples[0].HTTPStatus != 500 { + t.Errorf("expected sample HTTPStatus 500, got %d", iss.Samples[0].HTTPStatus) + } + } + } + + if !foundNginx { + t.Errorf("expected to find nginx issue") + } + if !foundE2E { + t.Errorf("expected to find e2e-verify issue") + } +} + func TestConsumeLogsRecordsIssue(t *testing.T) { coll := New(noopBroadcaster{}, 10, nil) @@ -320,6 +392,41 @@ func TestHandleIssuesAndByID(t *testing.T) { } } +func TestHandleIssuesFiltersAndPaginates(t *testing.T) { + coll := New(noopBroadcaster{}, 10, nil) + coll.issues.Record("span_error", "nginx", "GET /.env", issues.Sample{Timestamp: 300, SourceIP: "203.0.113.10", HTTPRoute: "/.env"}) + coll.issues.Record("span_error", "nginx", "GET /.git/HEAD", issues.Sample{Timestamp: 200, SourceIP: "203.0.113.11", HTTPRoute: "/.git/HEAD"}) + coll.issues.Record("log_error", "api", "database down", issues.Sample{Timestamp: 100}) + + req := httptest.NewRequest(http.MethodGet, "/api/issues?service=nginx&kind=span_error&limit=1", nil) + rec := httptest.NewRecorder() + coll.HandleIssues(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var page []issues.Issue + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatalf("failed to decode issues page: %v", err) + } + if len(page) != 1 || page[0].Service != "nginx" { + t.Fatalf("unexpected filtered page: %+v", page) + } + cursor := rec.Header().Get("X-Next-Cursor") + if cursor == "" { + t.Fatal("expected next cursor header") + } + + req = httptest.NewRequest(http.MethodGet, "/api/issues?service=nginx&limit=1&cursor="+cursor, nil) + rec = httptest.NewRecorder() + coll.HandleIssues(rec, req) + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatalf("failed to decode cursor page: %v", err) + } + if len(page) != 1 || page[0].Title != "GET /.git/HEAD" { + t.Fatalf("unexpected cursor page: %+v", page) + } +} + func TestHandleLogsIngestKey(t *testing.T) { coll := New(noopBroadcaster{}, 10, nil) coll.SetIngestKey("secret") diff --git a/internal/collector/handlers.go b/internal/collector/handlers.go index 2210a15..139a415 100644 --- a/internal/collector/handlers.go +++ b/internal/collector/handlers.go @@ -4,7 +4,9 @@ package collector import ( "context" + "encoding/base64" "encoding/json" + "fmt" "io" "log" "net/http" @@ -12,6 +14,7 @@ import ( "strings" "time" + "git.tornberg.me/go-otel/internal/issues" "git.tornberg.me/go-otel/internal/storage" "git.tornberg.me/go-otel/internal/websocket" "go.opentelemetry.io/collector/pdata/plog" @@ -378,35 +381,282 @@ func (c *Collector) HandleIssues(w http.ResponseWriter, r *http.Request) { return } - list := c.ListIssues(parseLimit(r, 100)) + opts, err := parseIssueListOptions(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + page := c.ListIssuesFiltered(opts) w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(list); err != nil { + if page.HasMore && len(page.Issues) > 0 { + last := page.Issues[len(page.Issues)-1] + next := encodeIssueCursor(last.LastSeen, last.ID) + w.Header().Set("X-Next-Cursor", next) + nextQuery := r.URL.Query() + nextQuery.Set("cursor", next) + w.Header().Set("Link", fmt.Sprintf("<%s?%s>; rel=next", r.URL.Path, nextQuery.Encode())) + } + // Keep the response as a JSON array for compatibility. Pagination metadata + // is exposed in headers so existing UI clients do not need a migration. + if err := json.NewEncoder(w).Encode(page.Issues); err != nil { log.Printf("failed to encode issues: %v", err) http.Error(w, "failed to encode issues", http.StatusInternalServerError) return } } +const maxIssuePageSize = 200 + +type issueCursor struct { + LastSeen int64 `json:"lastSeen"` + ID string `json:"id"` +} + +func parseIssueListOptions(r *http.Request) (issues.ListOptions, error) { + q := r.URL.Query() + opts := issues.ListOptions{ + Service: q.Get("service"), + Kind: q.Get("kind"), + Status: q.Get("status"), + Title: q.Get("q"), + SourceIP: q.Get("sourceIp"), + Route: q.Get("route"), + Limit: parseLimit(r, 100), + } + if opts.Limit <= 0 { + opts.Limit = 100 + } + if opts.Limit > maxIssuePageSize { + opts.Limit = maxIssuePageSize + } + for name, dst := range map[string]*int64{"since": &opts.Since, "until": &opts.Until} { + if value := q.Get(name); value != "" { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return issues.ListOptions{}, fmt.Errorf("invalid %s; expected unix nanoseconds", name) + } + *dst = parsed + } + } + if opts.Since != 0 && opts.Until != 0 && opts.Since > opts.Until { + return issues.ListOptions{}, fmt.Errorf("since must be less than or equal to until") + } + if value := q.Get("cursor"); value != "" { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return issues.ListOptions{}, fmt.Errorf("invalid cursor") + } + var cursor issueCursor + if err := json.Unmarshal(decoded, &cursor); err != nil || cursor.LastSeen <= 0 || cursor.ID == "" { + return issues.ListOptions{}, fmt.Errorf("invalid cursor") + } + opts.AfterLastSeen = cursor.LastSeen + opts.AfterID = cursor.ID + } + return opts, nil +} + +func encodeIssueCursor(lastSeen int64, id string) string { + data, _ := json.Marshal(issueCursor{LastSeen: lastSeen, ID: id}) + return base64.RawURLEncoding.EncodeToString(data) +} + +// HandleSourceMap accepts a JavaScript source map for a deployed release. +// Multipart fields: release, source, file. +func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if c.sourceMaps == nil { + http.Error(w, "source maps are not configured", http.StatusNotImplemented) + return + } + if err := r.ParseMultipartForm(50 << 20); err != nil { + http.Error(w, "invalid multipart body", http.StatusBadRequest) + return + } + release, source := r.FormValue("release"), r.FormValue("source") + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "file is required", http.StatusBadRequest) + return + } + defer file.Close() + if err := c.sourceMaps.Upload(release, source, file); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) +} + // HandleIssueByID returns a single issue by id, including its samples. func (c *Collector) HandleIssueByID(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + switch r.Method { + case http.MethodGet: + issue, ok := c.GetIssue(id) + if !ok { + http.Error(w, "issue not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(issue); err != nil { + log.Printf("failed to encode issue: %v", err) + http.Error(w, "failed to encode issue", http.StatusInternalServerError) + } + case http.MethodPatch: + c.HandleIssueStatus(w, r, id) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// HandlePatterns returns deterministic related-issue groups for visualization. +func (c *Collector) HandlePatterns(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(c.ListPatterns()) +} - id := r.PathValue("id") - issue, ok := c.GetIssue(id) +// HandleRollouts returns normalized Kubernetes Deployment rollout history. +func (c *Collector) HandleRollouts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.KubernetesEnabled() { + jsonError(w, http.StatusNotImplemented, c.KubernetesError()) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(c.ListRollouts()) +} + +func (c *Collector) HandleRolloutByID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.KubernetesEnabled() { + jsonError(w, http.StatusNotImplemented, c.KubernetesError()) + return + } + rollout, ok := c.GetRollout(r.PathValue("id")) + if !ok { + http.Error(w, "rollout not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rollout) +} + +func (c *Collector) HandleRolloutImpact(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.KubernetesEnabled() { + jsonError(w, http.StatusNotImplemented, c.KubernetesError()) + return + } + rollout, ok := c.GetRollout(r.PathValue("id")) + if !ok { + http.Error(w, "rollout not found", http.StatusNotFound) + return + } + response := map[string]interface{}{"configured": c.prometheus != nil, "rollout": rollout, "points": []interface{}{}} + if c.prometheus != nil { + start := time.Unix(0, rollout.StartedAt).Add(-15 * time.Minute) + end := time.Now() + if start.Before(end.Add(-24 * time.Hour)) { + start = end.Add(-24 * time.Hour) + } + points, err := c.prometheus.ErrorRate(r.Context(), rollout.Service, rollout.Release, start, end) + if err != nil { + jsonError(w, http.StatusBadGateway, err.Error()) + return + } + response["points"] = points + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +func jsonError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// HandleIssueSimilar returns explainable, non-merging related-issue suggestions. +func (c *Collector) HandleIssueSimilar(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + limit := parseLimit(r, 10) + result, ok := c.SimilarIssues(r.PathValue("id"), limit) if !ok { http.Error(w, "issue not found", http.StatusNotFound) return } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} + +// HandleSimilarityFeedback records a human review label for a pair of issues. +// Body: {"issueId":"...", "decision":"same|different"}. +func (c *Collector) HandleSimilarityFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + IssueID string `json:"issueId"` + Decision string `json:"decision"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if !c.SetSimilarityFeedback(r.PathValue("id"), req.IssueID, req.Decision) { + http.Error(w, "invalid issue pair or decision", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "recorded", "decision": req.Decision}) +} + +// HandleIssueStatus updates the triage state (open/resolved/ignored) of an +// issue. Body: {"status": "resolved"}. +func (c *Collector) HandleIssueStatus(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + Status string `json:"status"` + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + issue, ok := c.SetIssueStatus(id, req.Status) + if !ok { + http.Error(w, "issue not found or invalid status", http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(issue); err != nil { log.Printf("failed to encode issue: %v", err) - http.Error(w, "failed to encode issue", http.StatusInternalServerError) - return } } @@ -418,3 +668,102 @@ func (c *Collector) HandleHealth(w http.ResponseWriter, r *http.Request) { log.Printf("failed to encode health check: %v", err) } } + +// statsEnabled reports whether an aggregate-capable store is configured. +func (c *Collector) statsEnabled() bool { return c.statsQuerier != nil } + +// statsNotEnabled writes the response used when the file store is active: +// a stable 501 the UI treats as "view not available". +func statsNotEnabled(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "stats require OTEL_CLICKHOUSE_ADDR (ClickHouse storage) to be configured", + }) +} + +// parseStatsCommon reads the shared service/hours params for stats endpoints. +func parseStatsCommon(r *http.Request) (service string, hours int) { + service = r.URL.Query().Get("service") + hours = 72 + if h, err := strconv.Atoi(r.URL.Query().Get("hours")); err == nil && h > 0 && h <= 24*90 { + hours = h + } + return service, hours +} + +// HandleStatsErrors returns hourly/daily total and error counts for the +// trends graphs. Only available with ClickHouse storage. +func (c *Collector) HandleStatsErrors(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.statsEnabled() { + statsNotEnabled(w) + return + } + + service, hours := parseStatsCommon(r) + daily := hours > 24*14 // beyond two weeks, daily buckets read better + points, err := c.statsQuerier.ErrorStats(r.Context(), service, hours, daily) + if err != nil { + log.Printf("failed to compute error stats: %v", err) + http.Error(w, "failed to compute error stats", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "granularity": map[bool]string{true: "day", false: "hour"}[daily], + "points": points, + }) +} + +// HandleStatsReleases returns per-release error-count time series so two +// deploys can be compared line-by-line. Only available with ClickHouse. +func (c *Collector) HandleStatsReleases(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.statsEnabled() { + statsNotEnabled(w) + return + } + + service, hours := parseStatsCommon(r) + series, err := c.statsQuerier.ReleaseSeries(r.Context(), service, hours) + if err != nil { + log.Printf("failed to compute release stats: %v", err) + http.Error(w, "failed to compute release stats", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "series": series, + }) +} + +// HandleStatsServices lists the distinct services present in the rollup. +func (c *Collector) HandleStatsServices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.statsEnabled() { + statsNotEnabled(w) + return + } + + services, err := c.statsQuerier.Services(r.Context()) + if err != nil { + log.Printf("failed to list services: %v", err) + http.Error(w, "failed to list services", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"services": services}) +} diff --git a/internal/collector/issue_extract.go b/internal/collector/issue_extract.go index 26d0699..666c1d5 100644 --- a/internal/collector/issue_extract.go +++ b/internal/collector/issue_extract.go @@ -1,6 +1,8 @@ package collector import ( + "fmt" + "strconv" "strings" "git.tornberg.me/go-otel/internal/issues" @@ -9,46 +11,46 @@ import ( "go.opentelemetry.io/collector/pdata/ptrace" ) -// maxIssueTitleLen bounds how much of a log body is kept as an issue title, -// so a single noisy log line can't blow up issue payload size. +// maxIssueTitleLen bounds how much of a log body or span title is kept, +// so a single noisy error can't blow up issue payload size. const maxIssueTitleLen = 200 -// extractSpanErrors scans traces for spans with an error status and records -// one issue occurrence per error found. -func extractSpanErrors(tracker *issues.Tracker, td ptrace.Traces, timestamp int64) { +// extractSpanErrors scans traces for spans with an error status or error HTTP/gRPC +// status code, recording one issue occurrence per error found. It reports +// whether any error was seen, so the caller can notify live clients. +func extractSpanErrors(tracker *issues.Tracker, td ptrace.Traces, timestamp int64) bool { + found := false resourceSpans := td.ResourceSpans() for i := 0; i < resourceSpans.Len(); i++ { rs := resourceSpans.At(i) - service := resourceServiceName(rs.Resource()) scopeSpans := rs.ScopeSpans() for j := 0; j < scopeSpans.Len(); j++ { spans := scopeSpans.At(j).Spans() for k := 0; k < spans.Len(); k++ { span := spans.At(k) - if span.Status().Code() != ptrace.StatusCodeError { + if !isSpanError(span) { continue } + service := spanServiceName(rs.Resource(), span) title := spanErrorTitle(span) - tracker.Record("span_error", service, title, issues.Sample{ - Timestamp: timestamp, - TraceID: idString(span.TraceID()), - SpanID: idString(span.SpanID()), - Message: title, - }) + found = true + tracker.Record("span_error", service, title, spanSample(span, rs.Resource(), timestamp, title)) } } } + return found } // extractLogErrors scans logs for error/fatal severity records and records -// one issue occurrence per error found. -func extractLogErrors(tracker *issues.Tracker, ld plog.Logs, timestamp int64) { +// one issue occurrence per error found. It reports whether any error was +// seen, so the caller can notify live clients. +func extractLogErrors(tracker *issues.Tracker, ld plog.Logs, timestamp int64) bool { + found := false resourceLogs := ld.ResourceLogs() for i := 0; i < resourceLogs.Len(); i++ { rl := resourceLogs.At(i) - service := resourceServiceName(rl.Resource()) scopeLogs := rl.ScopeLogs() for j := 0; j < scopeLogs.Len(); j++ { @@ -59,23 +61,223 @@ func extractLogErrors(tracker *issues.Tracker, ld plog.Logs, timestamp int64) { continue } + service := logServiceName(rl.Resource(), record) title := logErrorTitle(record) + found = true kind := "log_error" - var url string + sample := logSample(record, rl.Resource(), timestamp, title) if attrString(record.Attributes(), "source") == "frontend" { kind = "frontend_error" - url = attrString(record.Attributes(), "browser.url") + sample.URL = attrString(record.Attributes(), "browser.url") } - tracker.Record(kind, service, title, issues.Sample{ - Timestamp: timestamp, - TraceID: idString(record.TraceID()), - SpanID: idString(record.SpanID()), - Message: title, - URL: url, - }) + tracker.Record(kind, service, title, sample) } } } + return found +} + +// spanSample builds an enriched issue sample from a span's attributes: +// stacktrace from exception events, HTTP request context from semconv +// attributes (old and new names), and client/release metadata. +func spanSample(span ptrace.Span, res pcommon.Resource, timestamp int64, title string) issues.Sample { + sample := issues.Sample{ + Timestamp: timestamp, + TraceID: idString(span.TraceID()), + SpanID: idString(span.SpanID()), + Message: title, + HTTPMethod: firstAttr(span.Attributes(), "http.request.method", "http.method"), + HTTPRoute: firstAttr(span.Attributes(), "http.route", "http.target", "url.path"), + UserAgent: firstAttr(span.Attributes(), "user_agent.original", "http.user_agent"), + SourceIP: sourceIP(span.Attributes(), res.Attributes()), + Release: firstAttr(span.Attributes(), "service.release"), + Environment: resourceEnvironment(res), + } + if status, ok := getHTTPStatusCode(span.Attributes()); ok { + sample.HTTPStatus = status + } + sample.Stacktrace = spanStacktrace(span) + return sample +} + +// logSample builds an enriched issue sample from a log record's attributes. +func logSample(record plog.LogRecord, res pcommon.Resource, timestamp int64, title string) issues.Sample { + sample := issues.Sample{ + Timestamp: timestamp, + TraceID: idString(record.TraceID()), + SpanID: idString(record.SpanID()), + Message: title, + Stacktrace: attrString(record.Attributes(), "exception.stacktrace"), + HTTPMethod: firstAttr(record.Attributes(), "http.request.method", "http.method"), + HTTPRoute: firstAttr(record.Attributes(), "http.route", "http.target", "url.path"), + UserAgent: firstAttr(record.Attributes(), "user_agent.original", "http.user_agent", "browser.userAgent"), + SourceIP: sourceIP(record.Attributes(), res.Attributes()), + Release: firstAttr(record.Attributes(), "service.release"), + Environment: firstAttr(record.Attributes(), "service.environment"), + Breadcrumbs: attrString(record.Attributes(), "breadcrumbs"), + } + if status, ok := getHTTPStatusCode(record.Attributes()); ok { + sample.HTTPStatus = status + } + if sample.Environment == "" { + sample.Environment = resourceEnvironment(res) + } + return sample +} + +// sourceIP extracts common client-IP attributes without trusting arbitrary +// request headers. Instrumentation may attach these to either the span/log or +// its resource. +func sourceIP(attrs, resource pcommon.Map) string { + ip := firstAttr(attrs, + "client.address", + "client.ip", + "http.client_ip", + "net.peer.ip", + "network.peer.address", + "http.request.header.x-real-ip", + "http.request.header.x-forwarded-for", + "http.request.header.x_real_ip", + "http.request.header.x_forwarded_for", + ) + if ip == "" { + ip = firstAttr(resource, + "client.address", + "client.ip", + "http.client_ip", + "net.peer.ip", + "network.peer.address", + ) + } + return strings.TrimSpace(strings.SplitN(ip, ",", 2)[0]) +} + +// spanStacktrace returns exception.stacktrace from the span's exception +// events, falling back to the span status message when it looks like an +// exception ("type: message" is how spanErrorTitle renders one). +func spanStacktrace(span ptrace.Span) string { + events := span.Events() + for i := 0; i < events.Len(); i++ { + event := events.At(i) + if event.Name() != "exception" { + continue + } + if st := attrString(event.Attributes(), "exception.stacktrace"); st != "" { + return truncate(st) + } + } + return "" +} + +// firstAttr returns the first present attribute among the given keys. +func firstAttr(attrs pcommon.Map, keys ...string) string { + for _, key := range keys { + if v, ok := attrs.Get(key); ok { + return v.AsString() + } + } + return "" +} + +// getHTTPStatusCode extracts an HTTP status code from attributes, supporting +// both new (http.response.status_code) and old (http.status_code) semconv keys, +// stored as integer or string. +func getHTTPStatusCode(attrs pcommon.Map) (int, bool) { + for _, key := range []string{"http.response.status_code", "http.status_code"} { + if v, ok := attrs.Get(key); ok { + switch v.Type() { + case pcommon.ValueTypeInt: + return int(v.Int()), true + case pcommon.ValueTypeStr: + if code, err := strconv.Atoi(v.Str()); err == nil { + return code, true + } + default: + if code, err := strconv.Atoi(v.AsString()); err == nil { + return code, true + } + } + } + } + return 0, false +} + +// httpStatusText maps common HTTP error status codes to their canonical reason phrase. +func httpStatusText(code int) string { + switch code { + case 400: + return "Bad Request" + case 401: + return "Unauthorized" + case 402: + return "Payment Required" + case 403: + return "Forbidden" + case 404: + return "Not Found" + case 405: + return "Method Not Allowed" + case 406: + return "Not Acceptable" + case 408: + return "Request Timeout" + case 409: + return "Conflict" + case 410: + return "Gone" + case 415: + return "Unsupported Media Type" + case 422: + return "Unprocessable Entity" + case 429: + return "Too Many Requests" + case 500: + return "Internal Server Error" + case 501: + return "Not Implemented" + case 502: + return "Bad Gateway" + case 503: + return "Service Unavailable" + case 504: + return "Gateway Timeout" + default: + if code >= 400 && code < 500 { + return "Client Error" + } + if code >= 500 && code < 600 { + return "Server Error" + } + return "" + } +} + +// isSpanError determines if a span represents an error, checking: +// 1. Explicit StatusCodeError +// 2. HTTP response status >= 400 +// 3. gRPC error code (> 0) +func isSpanError(span ptrace.Span) bool { + if span.Status().Code() == ptrace.StatusCodeError { + return true + } + if status, ok := getHTTPStatusCode(span.Attributes()); ok && status >= 400 { + return true + } + if grpcStr := firstAttr(span.Attributes(), "rpc.grpc.status_code"); grpcStr != "" { + if code, err := strconv.Atoi(grpcStr); err == nil && code > 0 { + return true + } + } + return false +} + +// resourceEnvironment reads deployment.environment (new semconv) or +// deployment.environment.name from a resource. +func resourceEnvironment(res pcommon.Resource) string { + if v := attrString(res.Attributes(), "deployment.environment.name"); v != "" { + return v + } + return attrString(res.Attributes(), "deployment.environment") } func isErrorLog(record plog.LogRecord) bool { @@ -91,6 +293,7 @@ func isErrorLog(record plog.LogRecord) bool { } func spanErrorTitle(span ptrace.Span) string { + // 1. Exception events take top priority (panics, exceptions with stacktrace). events := span.Events() for i := 0; i < events.Len(); i++ { event := events.At(i) @@ -109,10 +312,72 @@ func spanErrorTitle(span ptrace.Span) string { } } - if msg := span.Status().Message(); msg != "" { - return span.Name() + ": " + msg + // 2. HTTP error titles: "HTTP 404 (Not Found): GET /path" or "HTTP 500 (Internal Server Error): /api" + if status, ok := getHTTPStatusCode(span.Attributes()); ok && status >= 400 { + statusDesc := fmt.Sprintf("HTTP %d", status) + if txt := httpStatusText(status); txt != "" { + statusDesc = fmt.Sprintf("HTTP %d (%s)", status, txt) + } + + method := firstAttr(span.Attributes(), "http.request.method", "http.method") + route := firstAttr(span.Attributes(), "http.route", "http.target", "url.path") + + var endpoint string + if method != "" && route != "" { + endpoint = method + " " + route + } else if route != "" { + endpoint = route + } else if span.Name() != "" && !strings.EqualFold(span.Name(), "http") { + endpoint = span.Name() + } + + statusMsg := span.Status().Message() + switch { + case endpoint != "" && statusMsg != "" && !strings.EqualFold(statusMsg, httpStatusText(status)): + return truncate(statusDesc + ": " + endpoint + " (" + statusMsg + ")") + case endpoint != "": + return truncate(statusDesc + ": " + endpoint) + case statusMsg != "": + return truncate(statusDesc + ": " + statusMsg) + default: + return statusDesc + } } - return span.Name() + " (error)" + + // 3. gRPC error titles + if grpcStr := firstAttr(span.Attributes(), "rpc.grpc.status_code"); grpcStr != "" { + if code, err := strconv.Atoi(grpcStr); err == nil && code > 0 { + method := firstAttr(span.Attributes(), "rpc.method") + service := firstAttr(span.Attributes(), "rpc.service") + target := span.Name() + if service != "" && method != "" { + target = service + "/" + method + } + msg := span.Status().Message() + if msg != "" { + return truncate(fmt.Sprintf("gRPC error %d: %s (%s)", code, target, msg)) + } + if target != "" { + return truncate(fmt.Sprintf("gRPC error %d: %s", code, target)) + } + return fmt.Sprintf("gRPC error %d", code) + } + } + + // 4. Status message with span name + if msg := span.Status().Message(); msg != "" { + if span.Name() != "" { + return truncate(span.Name() + ": " + msg) + } + return truncate(msg) + } + + // 5. Fallback to span name + if span.Name() != "" { + return truncate(span.Name() + " (error)") + } + + return "span error" } func logErrorTitle(record plog.LogRecord) string { @@ -139,8 +404,28 @@ func truncate(s string) string { return s[:maxIssueTitleLen] + "…" } +func spanServiceName(res pcommon.Resource, span ptrace.Span) string { + if name := firstAttr(res.Attributes(), "service.name", "app.kubernetes.io/name", "app", "name"); name != "" && name != "unknown" { + return name + } + if name := firstAttr(span.Attributes(), "service.name", "app.kubernetes.io/name", "app", "name"); name != "" && name != "unknown" { + return name + } + return "unknown" +} + +func logServiceName(res pcommon.Resource, record plog.LogRecord) string { + if name := firstAttr(res.Attributes(), "service.name", "app.kubernetes.io/name", "app", "name"); name != "" && name != "unknown" { + return name + } + if name := firstAttr(record.Attributes(), "service.name", "app.kubernetes.io/name", "app", "name"); name != "" && name != "unknown" { + return name + } + return "unknown" +} + func resourceServiceName(res pcommon.Resource) string { - if name := attrString(res.Attributes(), "service.name"); name != "" { + if name := firstAttr(res.Attributes(), "service.name", "app.kubernetes.io/name", "app", "name"); name != "" && name != "unknown" { return name } return "unknown" diff --git a/internal/honeypot/honeypot.go b/internal/honeypot/honeypot.go new file mode 100644 index 0000000..e53b3ff --- /dev/null +++ b/internal/honeypot/honeypot.go @@ -0,0 +1,245 @@ +// Package honeypot tracks common vulnerability-scanner requests and serves an +// inert decoy response. Its promoted IP list can be consumed by an ingress +// controller or a separate synchronizer. +package honeypot + +import ( + "crypto/subtle" + "encoding/json" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultThreshold = 3 + defaultWindow = 15 * time.Minute + defaultTTL = 1 * time.Hour + maxTracked = 10000 +) + +type Config struct { + Threshold int + Window time.Duration + TTL time.Duration + Delay time.Duration + Token string +} + +type entry struct { + Count int + FirstSeen time.Time + LastSeen time.Time + PromotedUntil time.Time +} + +// IP describes an IP promoted for ingress honeypot routing. +type IP struct { + Address string `json:"ip"` + Count int `json:"count"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + PromotedUntil time.Time `json:"promoted_until"` +} + +type Tracker struct { + threshold int + window time.Duration + ttl time.Duration + delay time.Duration + token string + + mu sync.Mutex + clients map[string]entry +} + +func New(cfg Config) *Tracker { + if cfg.Threshold <= 0 { + cfg.Threshold = defaultThreshold + } + if cfg.Window <= 0 { + cfg.Window = defaultWindow + } + if cfg.TTL <= 0 { + cfg.TTL = defaultTTL + } + if cfg.Delay < 0 { + cfg.Delay = 0 + } + if cfg.Delay > 10*time.Second { + cfg.Delay = 10 * time.Second + } + return &Tracker{threshold: cfg.Threshold, window: cfg.Window, ttl: cfg.TTL, delay: cfg.Delay, token: cfg.Token, clients: make(map[string]entry)} +} + +func ConfigFromEnv() Config { + cfg := Config{Threshold: defaultThreshold, Window: defaultWindow, TTL: defaultTTL, Delay: 0, Token: os.Getenv("OTEL_HONEYPOT_TOKEN")} + if n, err := strconv.Atoi(os.Getenv("OTEL_HONEYPOT_THRESHOLD")); err == nil && n > 0 { + cfg.Threshold = n + } + if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_WINDOW")); err == nil && d > 0 { + cfg.Window = d + } + if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_TTL")); err == nil && d > 0 { + cfg.TTL = d + } + if d, err := time.ParseDuration(os.Getenv("OTEL_HONEYPOT_DELAY")); err == nil && d >= 0 { + cfg.Delay = d + } + return cfg +} + +// Middleware records scanner paths and returns decoys. All other requests +// continue to the wrapped application unchanged. +func (t *Tracker) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isSuspiciousPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + ip := clientIP(r) + promoted := t.record(ip) + if promoted && t.delay > 0 { + timer := time.NewTimer(t.delay) + select { + case <-timer.C: + case <-r.Context().Done(): + if !timer.Stop() { + <-timer.C + } + return + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if strings.Contains(strings.ToLower(r.URL.Path), ".env") { + _, _ = w.Write([]byte("# decoy configuration - no real secrets\nAPP_ENV=production\nAPP_DEBUG=false\nDB_HOST=127.0.0.1\nDB_DATABASE=example_decoy\nDB_USERNAME=decoy_reader\nDB_PASSWORD=not-a-real-password\n")) + return + } + _, _ = w.Write([]byte("-- decoy diagnostic response\n-- no database connection was made\n-- DROP TABLE users;\n-- DROP TABLE sessions;\n-- inert text only\n")) + }) +} + +func (t *Tracker) record(ip string) bool { + now := time.Now() + t.mu.Lock() + defer t.mu.Unlock() + state, ok := t.clients[ip] + if !ok || now.Sub(state.LastSeen) > t.window { + state = entry{FirstSeen: now} + } + state.Count++ + state.LastSeen = now + if state.Count > t.threshold { + state.PromotedUntil = now.Add(t.ttl) + } + t.clients[ip] = state + if len(t.clients) > maxTracked { + t.evictOldest() + } + return !state.PromotedUntil.IsZero() && now.Before(state.PromotedUntil) +} + +// PromotedIPs returns a snapshot suitable for an ingress synchronizer. +func (t *Tracker) PromotedIPs() []IP { + now := time.Now() + t.mu.Lock() + defer t.mu.Unlock() + result := make([]IP, 0) + for address, state := range t.clients { + if state.PromotedUntil.IsZero() || !now.Before(state.PromotedUntil) { + continue + } + result = append(result, IP{Address: address, Count: state.Count, FirstSeen: state.FirstSeen, LastSeen: state.LastSeen, PromotedUntil: state.PromotedUntil}) + } + return result +} + +// HandleIPs exposes the promoted list only when OTEL_HONEYPOT_TOKEN is set. +// It is intended for a private ingress-side synchronizer, not public clients. +func (t *Tracker) HandleIPs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || t.token == "" || !validToken(r, t.token) { + w.Header().Set("WWW-Authenticate", `Bearer realm="honeypot"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + t.writeIPs(w) +} + +// HandleIPsForUser is used behind the application's normal authenticated UI +// middleware. The internal machine endpoint remains protected by Token. +func (t *Tracker) HandleIPsForUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + t.writeIPs(w) +} + +func (t *Tracker) writeIPs(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(t.PromotedIPs()) +} + +func validToken(r *http.Request, token string) bool { + value := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if value == "" { + value = r.Header.Get("X-Honeypot-Token") + } + return len(value) == len(token) && subtle.ConstantTimeCompare([]byte(value), []byte(token)) == 1 +} + +func (t *Tracker) evictOldest() { + var address string + var oldest time.Time + for ip, state := range t.clients { + if address == "" || state.LastSeen.Before(oldest) { + address, oldest = ip, state.LastSeen + } + } + if address != "" { + delete(t.clients, address) + } +} + +func isSuspiciousPath(path string) bool { + path = strings.ToLower(path) + for _, marker := range []string{ + "/.env", "/.git/", "/.svn/", "/wp-", "/xmlrpc.php", "/phpmyadmin", + "/pma/", "/vendor/phpunit", "/eval-stdin.php", "/cgi-bin/", "/shell.php", + "/cmd.php", "/upload.php", "/v2/keys", "/var/task/", "/dashboard/env-", "/app.js", + } { + if strings.Contains(path, marker) { + return true + } + } + for _, suffix := range []string{".php", ".ini", ".bak", ".swp", ".yml", ".yaml", ".sql", ".json", ".config", ".cfg"} { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func clientIP(r *http.Request) string { + // Prefer the single-value header normally overwritten by ingress-nginx. + // X-Forwarded-For can contain client-supplied values when a proxy appends + // rather than replaces it, so it is only a fallback. + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + return real + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + return strings.TrimSpace(strings.SplitN(forwarded, ",", 2)[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} diff --git a/internal/honeypot/honeypot_test.go b/internal/honeypot/honeypot_test.go new file mode 100644 index 0000000..6e23121 --- /dev/null +++ b/internal/honeypot/honeypot_test.go @@ -0,0 +1,41 @@ +package honeypot + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestPromotesAfterThresholdAndExpires(t *testing.T) { + tracker := New(Config{Threshold: 2, Window: time.Minute, TTL: time.Millisecond, Token: "secret"}) + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "https://example.test/.env", nil) + req.RemoteAddr = "192.0.2.10:1234" + tracker.Middleware(http.HandlerFunc(http.NotFound)).ServeHTTP(httptest.NewRecorder(), req) + } + if got := len(tracker.PromotedIPs()); got != 1 { + t.Fatalf("got %d promoted IPs, want 1", got) + } + time.Sleep(2 * time.Millisecond) + if got := len(tracker.PromotedIPs()); got != 0 { + t.Fatalf("got %d expired IPs, want 0", got) + } +} + +func TestHandleIPsRequiresToken(t *testing.T) { + tracker := New(Config{Threshold: 1, Token: "secret"}) + recorder := httptest.NewRecorder() + tracker.HandleIPs(recorder, httptest.NewRequest(http.MethodGet, "https://example.test/internal/honeypot/ips", nil)) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", recorder.Code) + } + recorder = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "https://example.test/internal/honeypot/ips", nil) + req.Header.Set("Authorization", "Bearer secret") + tracker.HandleIPs(recorder, req) + if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Body.String(), "[]") { + t.Fatalf("got %d and %q", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/issues/tracker.go b/internal/issues/tracker.go index 36b585b..5c51fec 100644 --- a/internal/issues/tracker.go +++ b/internal/issues/tracker.go @@ -5,9 +5,11 @@ package issues import ( "fmt" "hash/fnv" + "regexp" "sort" "strings" "sync" + "time" ) // maxIssues bounds memory use: once exceeded, the least-recently-seen issue @@ -17,6 +19,18 @@ const maxIssues = 2000 // maxSamples bounds how many sample occurrences are kept per issue. const maxSamples = 5 +// trendWindow bounds how far back the per-hour trend buckets are kept. +const trendWindow = 48 * time.Hour + +// Issue lifecycle states. New occurrences automatically reopen resolved +// issues (a regression), but stay merged into ignored ones until the user +// explicitly sets the state back to open. +const ( + StatusOpen = "open" + StatusResolved = "resolved" + StatusIgnored = "ignored" +) + // Sample is one occurrence of an issue. type Sample struct { Timestamp int64 `json:"timestamp"` @@ -26,38 +40,121 @@ type Sample struct { // URL is the page URL an occurrence happened on, populated only for // browser-originated (otel-rum) errors. URL string `json:"url,omitempty"` + // Stacktrace is the exception.stacktrace attribute of the occurrence, + // when the source telemetry carried one. + Stacktrace string `json:"stacktrace,omitempty"` + ResolvedStacktrace string `json:"resolvedStacktrace,omitempty"` + // HTTP request context, extracted from the span/log attributes when the + // error happened inside an HTTP handler (semconv v1.x old and new names + // are both handled by the extractor). + HTTPMethod string `json:"httpMethod,omitempty"` + HTTPRoute string `json:"httpRoute,omitempty"` + HTTPStatus int `json:"httpStatus,omitempty"` + // Client/release context. + UserAgent string `json:"userAgent,omitempty"` + SourceIP string `json:"sourceIp,omitempty"` + Release string `json:"release,omitempty"` + Environment string `json:"environment,omitempty"` + // Breadcrumbs is the raw JSON captured by otel-rum.js before the error + // (fetch/console events), present only on frontend issues. + Breadcrumbs string `json:"breadcrumbs,omitempty"` + // Deterministic, non-PII features used for grouping and review. + NormalizedMessage string `json:"normalizedMessage,omitempty"` + NormalizedRoute string `json:"normalizedRoute,omitempty"` + StatusClass string `json:"statusClass,omitempty"` + StackSignature string `json:"stackSignature,omitempty"` } // Issue is a deduplicated group of related errors, fingerprinted by kind, -// service and title. +// service and a normalized form of the title. type Issue struct { ID string `json:"id"` Title string `json:"title"` - Kind string `json:"kind"` // "span_error" or "log_error" + Kind string `json:"kind"` // "span_error", "log_error" or "frontend_error" Service string `json:"service"` Count int `json:"count"` FirstSeen int64 `json:"firstSeen"` LastSeen int64 `json:"lastSeen"` AffectedServices []string `json:"affectedServices"` Samples []Sample `json:"samples"` + // Trend maps UTC hour buckets ("2006-01-02T15:00") to occurrence counts + // over the last trendWindow, for sparklines and spike detection. + Trend map[string]int `json:"trend,omitempty"` + // Status is the triage state: open, resolved or ignored. + Status string `json:"status"` + ResolvedAt int64 `json:"resolvedAt,omitempty"` + // NormalizedTitle is the deterministic, redacted form used for exact grouping. + NormalizedTitle string `json:"normalizedTitle,omitempty"` + // PatternID groups related exact issues without merging their operational IDs. + PatternID string `json:"patternId,omitempty"` + PatternSignature string `json:"patternSignature,omitempty"` +} + +// SimilarIssue is a deterministic related-issue suggestion. Suggestions never +// change issue IDs and include the reasons that produced the score. +type SimilarIssue struct { + Issue Issue `json:"issue"` + Score float64 `json:"score"` + Reasons []string `json:"reasons"` + Decision string `json:"decision,omitempty"` +} + +// Pattern is a UI-friendly aggregate of exact issues. +type Pattern struct { + ID string `json:"id"` + Signature string `json:"signature"` + Service string `json:"service"` + Kind string `json:"kind"` + IssueCount int `json:"issueCount"` + OccurrenceCount int `json:"occurrenceCount"` + LastSeen int64 `json:"lastSeen"` + IssueIDs []string `json:"issueIds"` } // Tracker incrementally fingerprints and deduplicates errors as they're // recorded. It is safe for concurrent use. type Tracker struct { - mu sync.Mutex - issues map[string]*Issue + mu sync.Mutex + issues map[string]*Issue + feedback map[string]string +} + +// ListOptions filters and paginates the issue list. Issues remain ordered by +// LastSeen descending; AfterLastSeen/AfterID form an opaque-to-callers cursor +// boundary and make pagination stable when multiple issues share a timestamp. +type ListOptions struct { + Service string + Kind string + Status string + Title string + SourceIP string + Route string + Since int64 + Until int64 + AfterLastSeen int64 + AfterID string + Limit int +} + +// ListPage is a filtered page of issues. HasMore indicates that another page +// can be requested using the final issue as the cursor boundary. +type ListPage struct { + Issues []Issue + HasMore bool } // NewTracker creates an empty Tracker. func NewTracker() *Tracker { - return &Tracker{issues: make(map[string]*Issue)} + return &Tracker{issues: make(map[string]*Issue), feedback: make(map[string]string)} } // Record adds an occurrence of an error, creating a new issue or merging into -// an existing one with a matching (kind, service, title) fingerprint. +// an existing one with a matching (kind, service, normalized title) +// fingerprint. func (t *Tracker) Record(kind, service, title string, sample Sample) { - id := fingerprint(kind, service, title) + normalizeSample(&sample, title) + id := fingerprint(kind, service, NormalizeTitle(title)) + bucket := HourBucket(sample.Timestamp) t.mu.Lock() defer t.mu.Unlock() @@ -68,24 +165,282 @@ func (t *Tracker) Record(kind, service, title string, sample Sample) { t.evictOldestLocked() } issue = &Issue{ - ID: id, - Title: title, - Kind: kind, - Service: service, - FirstSeen: sample.Timestamp, + ID: id, + Title: title, + Kind: kind, + Service: service, + FirstSeen: sample.Timestamp, + Status: StatusOpen, + NormalizedTitle: NormalizeTitle(title), } + issue.PatternSignature = patternSignature(kind, service, title, sample) + issue.PatternID = fingerprint("pattern", issue.PatternSignature) t.issues[id] = issue } issue.Count++ issue.LastSeen = sample.Timestamp issue.AffectedServices = addUnique(issue.AffectedServices, service) + if issue.Trend == nil { + issue.Trend = make(map[string]int) + } + issue.Trend[bucket]++ + pruneTrendLocked(issue.Trend, sample.Timestamp) + // A resolved issue that fires again is a regression: reopen it. Ignored + // issues keep their state (that's the point of ignoring a noisy one). + if issue.Status == StatusResolved { + issue.Status = StatusOpen + issue.ResolvedAt = 0 + } issue.Samples = append(issue.Samples, sample) if len(issue.Samples) > maxSamples { issue.Samples = issue.Samples[len(issue.Samples)-maxSamples:] } } +// Similar returns bounded, deterministic near-match suggestions for an issue. +func (t *Tracker) Similar(id string, limit int) ([]SimilarIssue, bool) { + t.mu.Lock() + defer t.mu.Unlock() + target, ok := t.issues[id] + if !ok { + return nil, false + } + if limit <= 0 || limit > 50 { + limit = 10 + } + result := make([]SimilarIssue, 0) + for otherID, other := range t.issues { + if otherID == id || other.Service != target.Service || other.Kind != target.Kind { + continue + } + score, reasons := similarity(*target, *other) + if score < 0.45 { + continue + } + decision := t.feedback[feedbackKey(id, otherID)] + result = append(result, SimilarIssue{Issue: cloneIssue(other), Score: score, Reasons: reasons, Decision: decision}) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Score != result[j].Score { + return result[i].Score > result[j].Score + } + return result[i].Issue.ID < result[j].Issue.ID + }) + if len(result) > limit { + result = result[:limit] + } + return result, true +} + +// Patterns returns deterministic groups built from exact issue IDs. +func (t *Tracker) Patterns() []Pattern { + t.mu.Lock() + defer t.mu.Unlock() + groups := make(map[string]*Pattern) + for _, issue := range t.issues { + p := groups[issue.PatternID] + if p == nil { + p = &Pattern{ID: issue.PatternID, Signature: issue.PatternSignature, Service: issue.Service, Kind: issue.Kind} + groups[issue.PatternID] = p + } + p.IssueCount++ + p.OccurrenceCount += issue.Count + p.IssueIDs = append(p.IssueIDs, issue.ID) + if issue.LastSeen > p.LastSeen { + p.LastSeen = issue.LastSeen + } + } + result := make([]Pattern, 0, len(groups)) + for _, p := range groups { + sort.Strings(p.IssueIDs) + result = append(result, *p) + } + sort.Slice(result, func(i, j int) bool { + if result[i].LastSeen != result[j].LastSeen { + return result[i].LastSeen > result[j].LastSeen + } + return result[i].ID < result[j].ID + }) + return result +} + +// SetSimilarityFeedback records a human label for later training data. +func (t *Tracker) SetSimilarityFeedback(a, b, decision string) bool { + if a == "" || b == "" || a == b || (decision != "same" && decision != "different") { + return false + } + t.mu.Lock() + defer t.mu.Unlock() + if _, ok := t.issues[a]; !ok { + return false + } + if _, ok := t.issues[b]; !ok { + return false + } + t.feedback[feedbackKey(a, b)] = decision + return true +} + +func feedbackKey(a, b string) string { + if a > b { + a, b = b, a + } + return a + "\x00" + b +} + +func patternSignature(kind, service, title string, sample Sample) string { + route := sample.NormalizedRoute + status := sample.StatusClass + stack := sample.StackSignature + // The title is deliberately broad here: the exact normalized title remains + // the canonical issue identity, while this signature supports UI grouping. + title = normalizePatternTitle(title) + return strings.Join([]string{service, kind, route, status, title, stack}, "\x00") +} + +func normalizeSample(sample *Sample, title string) { + sample.NormalizedMessage = NormalizeTitle(title) + sample.NormalizedRoute = NormalizeRoute(sample.HTTPRoute) + sample.StatusClass = statusClass(sample.HTTPStatus) + sample.StackSignature = firstStackFrame(sample.Stacktrace) +} + +func normalizePatternTitle(title string) string { + s := NormalizeTitle(title) + s = regexp.MustCompile(`(?i)http\s+[1-5]\d{2}`).ReplaceAllString(s, "http ") + return s +} + +func NormalizeRoute(route string) string { + route = strings.TrimSpace(strings.SplitN(route, "?", 2)[0]) + if route == "" { + return "" + } + parts := strings.Split(route, "/") + for i, part := range parts { + if part == "" { + continue + } + if uuidRe.MatchString(part) || hexIDRe.MatchString(part) || numRe.MatchString(part) { + parts[i] = "{id}" + } + } + return strings.Join(parts, "/") +} + +func statusClass(status int) string { + if status >= 500 && status < 600 { + return "5xx" + } + if status >= 400 && status < 500 { + return "4xx" + } + if status >= 300 && status < 400 { + return "3xx" + } + if status > 0 { + return "2xx" + } + return "" +} + +func firstStackFrame(stack string) string { + if i := strings.IndexByte(stack, '\n'); i >= 0 { + stack = stack[:i] + } + return strings.TrimSpace(stack) +} + +func similarity(a, b Issue) (float64, []string) { + score := 0.0 + reasons := []string{} + if a.Service == b.Service { + score += .25 + } + if a.Kind == b.Kind { + score += .15 + } + aSample, bSample := latestSample(a), latestSample(b) + if NormalizeRoute(aSample.HTTPRoute) != "" && NormalizeRoute(aSample.HTTPRoute) == NormalizeRoute(bSample.HTTPRoute) { + score += .20 + reasons = append(reasons, "same route template") + } + if statusClass(aSample.HTTPStatus) != "" && statusClass(aSample.HTTPStatus) == statusClass(bSample.HTTPStatus) { + score += .15 + reasons = append(reasons, "same HTTP status class") + } + if firstStackFrame(aSample.Stacktrace) != "" && firstStackFrame(aSample.Stacktrace) == firstStackFrame(bSample.Stacktrace) { + score += .10 + reasons = append(reasons, "same top stack frame") + } + if a.NormalizedTitle != "" && a.NormalizedTitle == b.NormalizedTitle { + score += .15 + reasons = append(reasons, "same normalized message") + } else if tokenSimilarity(a.NormalizedTitle, b.NormalizedTitle) >= .5 { + score += .10 + reasons = append(reasons, "similar normalized message") + } + if len(reasons) == 0 { + reasons = append(reasons, "same service and event kind") + } + return score, reasons +} + +func latestSample(issue Issue) Sample { + if len(issue.Samples) == 0 { + return Sample{} + } + return issue.Samples[len(issue.Samples)-1] +} + +func tokenSimilarity(a, b string) float64 { + aa, bb := strings.Fields(a), strings.Fields(b) + if len(aa) == 0 || len(bb) == 0 { + return 0 + } + set := map[string]bool{} + for _, v := range aa { + set[v] = true + } + other := map[string]bool{} + for _, v := range bb { + other[v] = true + } + common := 0 + for v := range other { + if set[v] { + common++ + } + } + return float64(common) / float64(len(set)+len(other)-common) +} + +// SetStatus updates the triage state of an issue. It returns the updated +// issue, or false when the id is unknown or the status is invalid. +func (t *Tracker) SetStatus(id, status string) (Issue, bool) { + switch status { + case StatusOpen, StatusResolved, StatusIgnored: + default: + return Issue{}, false + } + + t.mu.Lock() + defer t.mu.Unlock() + + issue, ok := t.issues[id] + if !ok { + return Issue{}, false + } + issue.Status = status + if status == StatusOpen { + issue.ResolvedAt = 0 + } else { + issue.ResolvedAt = time.Now().UnixNano() + } + return cloneIssue(issue), true +} + // evictOldestLocked removes the issue with the smallest LastSeen. Callers // must hold t.mu. func (t *Tracker) evictOldestLocked() { @@ -107,22 +462,89 @@ func (t *Tracker) evictOldestLocked() { // List returns issues sorted by LastSeen descending (most recently seen // first). A non-positive limit returns all issues. func (t *Tracker) List(limit int) []Issue { + return t.ListFiltered(ListOptions{Limit: limit}).Issues +} + +// ListFiltered returns a filtered, stable page of issues. +func (t *Tracker) ListFiltered(opts ListOptions) ListPage { t.mu.Lock() defer t.mu.Unlock() result := make([]Issue, 0, len(t.issues)) for _, issue := range t.issues { + if !matches(issue, opts) || beforeCursor(issue, opts) { + continue + } result = append(result, cloneIssue(issue)) } sort.Slice(result, func(i, j int) bool { - return result[i].LastSeen > result[j].LastSeen + if result[i].LastSeen != result[j].LastSeen { + return result[i].LastSeen > result[j].LastSeen + } + return result[i].ID > result[j].ID }) - if limit > 0 && len(result) > limit { - result = result[:limit] + limit := opts.Limit + if limit <= 0 { + limit = len(result) } - return result + page := ListPage{Issues: result} + if len(page.Issues) > limit { + page.Issues = page.Issues[:limit] + page.HasMore = true + } + return page +} + +func matches(issue *Issue, opts ListOptions) bool { + if opts.Service != "" && issue.Service != opts.Service { + return false + } + if opts.Kind != "" && issue.Kind != opts.Kind { + return false + } + if opts.Status != "" && issue.Status != opts.Status { + return false + } + if opts.Title != "" && !strings.Contains(strings.ToLower(issue.Title), strings.ToLower(opts.Title)) { + return false + } + if opts.Since != 0 && issue.LastSeen < opts.Since { + return false + } + if opts.Until != 0 && issue.LastSeen > opts.Until { + return false + } + if opts.SourceIP != "" && !issueHasSample(issue, func(sample Sample) bool { return sample.SourceIP == opts.SourceIP }) { + return false + } + if opts.Route != "" && !issueHasSample(issue, func(sample Sample) bool { return sample.HTTPRoute == opts.Route }) { + return false + } + return true +} + +func issueHasSample(issue *Issue, predicate func(Sample) bool) bool { + for _, sample := range issue.Samples { + if predicate(sample) { + return true + } + } + return false +} + +func beforeCursor(issue *Issue, opts ListOptions) bool { + if opts.AfterLastSeen == 0 { + return false + } + if issue.LastSeen < opts.AfterLastSeen { + return false + } + if issue.LastSeen > opts.AfterLastSeen { + return true + } + return issue.ID >= opts.AfterID } // Get returns a single issue by id. @@ -137,15 +559,100 @@ func (t *Tracker) Get(id string) (Issue, bool) { return cloneIssue(issue), true } -// cloneIssue deep-copies the slice fields so returned issues don't share +// cloneIssue deep-copies the slice/map fields so returned issues don't share // backing arrays with ones still being mutated by Record. func cloneIssue(i *Issue) Issue { c := *i c.AffectedServices = append([]string(nil), i.AffectedServices...) c.Samples = append([]Sample(nil), i.Samples...) + if i.Trend != nil { + c.Trend = make(map[string]int, len(i.Trend)) + for k, v := range i.Trend { + c.Trend[k] = v + } + } return c } +// HourBucket renders a unix-nanosecond timestamp as a UTC hour key used in +// Issue.Trend. Keys sort lexicographically, so the UI can sort them as-is. +func HourBucket(ts int64) string { + return time.Unix(0, ts).UTC().Format("2006-01-02T15:00") +} + +// pruneTrendLocked drops trend buckets older than trendWindow. Callers must +// hold the tracker lock. +func pruneTrendLocked(trend map[string]int, now int64) { + if len(trend) <= 48 { + return + } + cutoff := HourBucket(time.Unix(0, now).Add(-trendWindow).UnixNano()) + for k := range trend { + if k < cutoff { + delete(trend, k) + } + } +} + +var ( + uuidRe = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) + hexIDRe = regexp.MustCompile(`(?i)\b[0-9a-f]{8,}\b`) + httpStatusRe = regexp.MustCompile(`(?i)\bhttp\s+([1-5]\d{2})\b`) + numRe = regexp.MustCompile(`\b\d+\b`) + spaceRe = regexp.MustCompile(`\s+`) +) + +// NormalizeTitle strips variable content (UUIDs, hex ids, numbers, whitespace, +// casing) from an error title so that "User 123 not found" and "User 456 not +// found" fingerprint into the same issue. The raw title is kept as the +// display title of the first-seen occurrence. HTTP status codes (e.g. HTTP 404) +// are preserved so different HTTP error types don't collapse together. +func NormalizeTitle(title string) string { + s := uuidRe.ReplaceAllString(title, "") + // Protect HTTP status codes from being replaced by + s = httpStatusRe.ReplaceAllStringFunc(s, func(match string) string { + parts := strings.Fields(match) + if len(parts) != 2 { + return match + } + return "__HTTP_STATUS_" + encodeStatus(parts[1]) + "__" + }) + s = numRe.ReplaceAllString(s, "") + s = hexIDRe.ReplaceAllStringFunc(s, func(match string) string { + // Mixed hex runs (letters + digits, e.g. trace/span ids) become ids. + // Pure-digit runs were already handled by numRe above, and words + // spelled entirely from a-f (rare at this length) survive so distinct + // error messages don't collapse. + if strings.ContainsAny(match, "0123456789") { + return "" + } + return match + }) + s = regexp.MustCompile(`__HTTP_STATUS_([a-z]+)__`).ReplaceAllStringFunc(s, func(match string) string { + encoded := strings.TrimSuffix(strings.TrimPrefix(match, "__HTTP_STATUS_"), "__") + return "http " + decodeStatus(encoded) + }) + s = strings.ReplaceAll(s, "__", "") + s = spaceRe.ReplaceAllString(s, " ") + return strings.ToLower(strings.TrimSpace(s)) +} + +func encodeStatus(code string) string { + var b strings.Builder + for _, digit := range code { + b.WriteByte('a' + byte(digit-'0')) + } + return b.String() +} + +func decodeStatus(encoded string) string { + var b strings.Builder + for _, letter := range encoded { + b.WriteByte('0' + byte(letter-'a')) + } + return b.String() +} + func fingerprint(parts ...string) string { h := fnv.New64a() _, _ = h.Write([]byte(strings.Join(parts, "\x00"))) diff --git a/internal/issues/tracker_test.go b/internal/issues/tracker_test.go index f46499c..20d79d6 100644 --- a/internal/issues/tracker_test.go +++ b/internal/issues/tracker_test.go @@ -73,3 +73,157 @@ func TestTrackerListRespectsLimit(t *testing.T) { t.Fatalf("expected limit to cap results at 2, got %d", len(list)) } } + +func TestTrackerFilteredCursorPagination(t *testing.T) { + tracker := NewTracker() + tracker.Record("span_error", "nginx", "GET /.env", Sample{Timestamp: 300, SourceIP: "203.0.113.10", HTTPRoute: "/.env"}) + tracker.Record("span_error", "nginx", "GET /.git/HEAD", Sample{Timestamp: 200, SourceIP: "203.0.113.11", HTTPRoute: "/.git/HEAD"}) + tracker.Record("log_error", "api", "database down", Sample{Timestamp: 100}) + + page := tracker.ListFiltered(ListOptions{Service: "nginx", Kind: "span_error", Route: "/.env", Limit: 1}) + if len(page.Issues) != 1 || page.Issues[0].Title != "GET /.env" { + t.Fatalf("unexpected filtered page: %+v", page) + } + if page.HasMore { + t.Error("single matching issue should not have another page") + } + + all := tracker.ListFiltered(ListOptions{Service: "nginx", Limit: 1}) + if len(all.Issues) != 1 || !all.HasMore { + t.Fatalf("expected first paginated page with more results, got %+v", all) + } + next := tracker.ListFiltered(ListOptions{ + Service: "nginx", + AfterLastSeen: all.Issues[0].LastSeen, + AfterID: all.Issues[0].ID, + Limit: 1, + }) + if len(next.Issues) != 1 || next.Issues[0].Title != "GET /.git/HEAD" || next.HasMore { + t.Fatalf("unexpected cursor page: %+v", next) + } +} + +func TestNormalizeTitleGroupsVariableMessages(t *testing.T) { + cases := [][3]string{ + // same, same, different: [a, b, expectedEqual(a,b)] + {"User 123 not found", "User 456 not found", "user not found"}, + {"order 550e8400-e29b-41d4-a716-446655440000 failed", "order 550e8400-e29b-41d4-a716-446655440001 failed", "order failed"}, + {"request 0a1b2c3d4e5f6a7b timed out", "request ff11aa22bb33cc44 timed out", "request timed out"}, + } + for _, c := range cases { + if got := NormalizeTitle(c[0]); got != c[2] { + t.Errorf("NormalizeTitle(%q) = %q, want %q", c[0], got, c[2]) + } + if NormalizeTitle(c[0]) != NormalizeTitle(c[1]) { + t.Errorf("expected %q and %q to normalize identically", c[0], c[1]) + } + } + // Words spelled from hex letters without digits must survive. + if got := NormalizeTitle("bad failed deadbeef badge"); got != "bad failed deadbeef badge" { + t.Errorf("word-like hex incorrectly replaced: %q", got) + } +} + +func TestTrackerNormalizedFingerprinting(t *testing.T) { + tracker := NewTracker() + tracker.Record("log_error", "api", "User 123 not found", Sample{Timestamp: 100, Message: "User 123 not found"}) + tracker.Record("log_error", "api", "User 98765432 not found", Sample{Timestamp: 200, Message: "User 98765432 not found"}) + + list := tracker.List(0) + if len(list) != 1 { + t.Fatalf("expected variable messages to merge into 1 issue, got %d", len(list)) + } + if list[0].Count != 2 { + t.Errorf("expected count 2, got %d", list[0].Count) + } + // The display title is the first-seen raw message. + if list[0].Title != "User 123 not found" { + t.Errorf("expected first-seen raw title kept, got %q", list[0].Title) + } +} + +func TestTrackerTrendBuckets(t *testing.T) { + tracker := NewTracker() + // Two occurrences in the same hour. + tracker.Record("log_error", "api", "boom", Sample{Timestamp: 100}) + tracker.Record("log_error", "api", "boom", Sample{Timestamp: 200}) + + issue, ok := tracker.Get(tracker.List(1)[0].ID) + if !ok { + t.Fatal("expected issue to exist") + } + bucket := HourBucket(100) + if issue.Trend[bucket] != 2 { + t.Errorf("expected trend[%s]=2, got %d", bucket, issue.Trend[bucket]) + } +} + +func TestTrackerStatusLifecycle(t *testing.T) { + tracker := NewTracker() + tracker.Record("log_error", "api", "boom", Sample{Timestamp: 100}) + id := tracker.List(1)[0].ID + + // Invalid status rejected. + if _, ok := tracker.SetStatus(id, "bogus"); ok { + t.Error("expected invalid status to be rejected") + } + // Unknown id rejected. + if _, ok := tracker.SetStatus("nope", StatusResolved); ok { + t.Error("expected unknown id to be rejected") + } + // Resolve. + resolved, ok := tracker.SetStatus(id, StatusResolved) + if !ok || resolved.Status != StatusResolved || resolved.ResolvedAt == 0 { + t.Fatalf("expected resolved issue, got %+v ok=%v", resolved, ok) + } + // A new occurrence reopens it (regression). + tracker.Record("log_error", "api", "boom", Sample{Timestamp: 200}) + issue, _ := tracker.Get(id) + if issue.Status != StatusOpen || issue.ResolvedAt != 0 { + t.Errorf("expected regression to reopen issue, got status=%s resolvedAt=%d", issue.Status, issue.ResolvedAt) + } + // Ignored survives new occurrences. + if _, ok := tracker.SetStatus(id, StatusIgnored); !ok { + t.Fatal("expected ignore to succeed") + } + tracker.Record("log_error", "api", "boom", Sample{Timestamp: 300}) + issue, _ = tracker.Get(id) + if issue.Status != StatusIgnored { + t.Errorf("expected ignored state to survive new occurrence, got %s", issue.Status) + } +} + +func TestTrackerPatternsAndSimilarSuggestions(t *testing.T) { + tracker := NewTracker() + tracker.Record("span_error", "checkout", "HTTP 504: database timeout for order 123", Sample{ + Timestamp: 100, HTTPRoute: "/orders/123", HTTPStatus: 504, Stacktrace: "db.go:42\nhandler.go:10", + }) + tracker.Record("span_error", "checkout", "HTTP 502: database timeout for order 456", Sample{ + Timestamp: 200, HTTPRoute: "/orders/456", HTTPStatus: 502, Stacktrace: "db.go:42\nhandler.go:10", + }) + issuesList := tracker.List(0) + if len(issuesList) != 2 { + t.Fatalf("expected separate exact issues, got %d", len(issuesList)) + } + if issuesList[0].PatternID == "" || issuesList[0].PatternID != issuesList[1].PatternID { + t.Fatalf("expected related HTTP variants to share a pattern: %+v", issuesList) + } + if issuesList[0].Samples[0].NormalizedRoute != "/orders/{id}" { + t.Fatalf("expected normalized route, got %q", issuesList[0].Samples[0].NormalizedRoute) + } + related, ok := tracker.Similar(issuesList[0].ID, 5) + if !ok || len(related) != 1 || related[0].Score < .45 { + t.Fatalf("expected deterministic related suggestion, got ok=%v related=%+v", ok, related) + } + if !tracker.SetSimilarityFeedback(issuesList[0].ID, related[0].Issue.ID, "same") { + t.Fatal("expected feedback to be accepted") + } + related, _ = tracker.Similar(issuesList[0].ID, 5) + if related[0].Decision != "same" { + t.Fatalf("expected feedback decision, got %q", related[0].Decision) + } + patterns := tracker.Patterns() + if len(patterns) != 1 || patterns[0].IssueCount != 2 || patterns[0].OccurrenceCount != 2 { + t.Fatalf("expected one two-issue pattern, got %+v", patterns) + } +} diff --git a/internal/kubernetes/types.go b/internal/kubernetes/types.go new file mode 100644 index 0000000..494f027 --- /dev/null +++ b/internal/kubernetes/types.go @@ -0,0 +1,23 @@ +package kubernetes + +// Rollout is the normalized, UI-safe view of a Kubernetes Deployment rollout. +type Rollout struct { + ID string `json:"id"` + Namespace string `json:"namespace"` + Workload string `json:"workload"` + Service string `json:"service,omitempty"` + Release string `json:"release,omitempty"` + Revision string `json:"revision,omitempty"` + Image string `json:"image,omitempty"` + Phase string `json:"phase"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + StartedAt int64 `json:"startedAt"` + CompletedAt int64 `json:"completedAt,omitempty"` + LastSeen int64 `json:"lastSeen"` + Generation int64 `json:"generation"` + DesiredReplicas int32 `json:"desiredReplicas"` + UpdatedReplicas int32 `json:"updatedReplicas"` + AvailableReplicas int32 `json:"availableReplicas"` + ReadyReplicas int32 `json:"readyReplicas"` +} diff --git a/internal/kubernetes/watcher.go b/internal/kubernetes/watcher.go new file mode 100644 index 0000000..85d15ef --- /dev/null +++ b/internal/kubernetes/watcher.go @@ -0,0 +1,239 @@ +// Package kubernetes observes read-only workload state for rollout correlation. +package kubernetes + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/clientcmd" +) + +const maxRollouts = 500 + +type Watcher struct { + mu sync.RWMutex + rollouts map[string]Rollout + enabled bool + err string +} + +// NewFromEnv enables observation in-cluster, or with KUBECONFIG locally. It is +// disabled by default outside Kubernetes so local development needs no kubeconfig. +func NewFromEnv(ctx context.Context) (*Watcher, error) { + if strings.EqualFold(os.Getenv("OTEL_KUBERNETES_ENABLED"), "false") { + return nil, nil + } + config, err := kubeConfig() + if err != nil { + if os.Getenv("OTEL_KUBERNETES_ENABLED") == "true" { + return nil, err + } + return nil, nil + } + client, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("create kubernetes client: %w", err) + } + + w := &Watcher{rollouts: make(map[string]Rollout), enabled: true} + namespace := os.Getenv("OTEL_KUBERNETES_NAMESPACE") + selector := os.Getenv("OTEL_KUBERNETES_LABEL_SELECTOR") + factory := informers.NewSharedInformerFactoryWithOptions(client, 30*time.Second, + informers.WithTweakListOptions(func(options *metav1.ListOptions) { + options.LabelSelector = selector + }), + ) + if namespace != "" { + factory = informers.NewSharedInformerFactoryWithOptions(client, 30*time.Second, + informers.WithNamespace(namespace), informers.WithTweakListOptions(func(options *metav1.ListOptions) { + options.LabelSelector = selector + })) + } + informer := factory.Apps().V1().Deployments().Informer() + _, _ = informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { w.observe(obj) }, + UpdateFunc: func(_, obj interface{}) { w.observe(obj) }, + }) + go func() { + factory.Start(ctx.Done()) + if !cache.WaitForCacheSync(ctx.Done(), informer.HasSynced) { + w.setError("deployment informer did not sync") + return + } + <-ctx.Done() + }() + return w, nil +} + +func kubeConfig() (*rest.Config, error) { + if config, err := rest.InClusterConfig(); err == nil { + return config, nil + } + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + kubeconfig = home + string(os.PathSeparator) + ".kube" + string(os.PathSeparator) + "config" + } + return clientcmd.BuildConfigFromFlags("", kubeconfig) +} + +func (w *Watcher) observe(obj interface{}) { + deployment, ok := obj.(*appsv1.Deployment) + if !ok { + return + } + now := time.Now().UnixNano() + revision := deployment.Annotations["deployment.kubernetes.io/revision"] + if revision == "" { + revision = strconv.FormatInt(deployment.Generation, 10) + } + key := deployment.Namespace + "/" + deployment.Name + "/" + revision + phase, reason, message, completed := rolloutStatus(deployment) + started := now + for _, condition := range deployment.Status.Conditions { + if condition.Type == appsv1.DeploymentProgressing && !condition.LastUpdateTime.IsZero() { + started = condition.LastUpdateTime.UnixNano() + } + if condition.Type == appsv1.DeploymentAvailable && !condition.LastUpdateTime.IsZero() && completed { + completed = condition.LastUpdateTime.UnixNano() != 0 + } + } + if old, exists := w.rollouts[key]; exists { + started = old.StartedAt + if old.CompletedAt != 0 { + completed = true + } + } + rollout := Rollout{ + ID: rolloutID(key), Namespace: deployment.Namespace, Workload: deployment.Name, + Service: metadataValue(deployment, "app.kubernetes.io/name", "app", "service.name"), + Release: metadataValue(deployment, "otel.k6n.net/release", "app.kubernetes.io/version", "service.release"), + Revision: revision, Image: deploymentImage(deployment), Phase: phase, Reason: reason, + Message: message, StartedAt: started, LastSeen: now, Generation: deployment.Generation, + DesiredReplicas: desiredReplicas(deployment), UpdatedReplicas: deployment.Status.UpdatedReplicas, + AvailableReplicas: deployment.Status.AvailableReplicas, ReadyReplicas: deployment.Status.ReadyReplicas, + } + if completed { + rollout.CompletedAt = now + } + w.mu.Lock() + w.rollouts[key] = rollout + if len(w.rollouts) > maxRollouts { + w.evictOldestLocked() + } + w.mu.Unlock() +} + +func (w *Watcher) evictOldestLocked() { + var oldest string + var ts int64 + for key, item := range w.rollouts { + if oldest == "" || item.LastSeen < ts { + oldest, ts = key, item.LastSeen + } + } + if oldest != "" { + delete(w.rollouts, oldest) + } +} +func (w *Watcher) setError(message string) { w.mu.Lock(); defer w.mu.Unlock(); w.err = message } + +func (w *Watcher) List() []Rollout { + w.mu.RLock() + defer w.mu.RUnlock() + result := make([]Rollout, 0, len(w.rollouts)) + for _, item := range w.rollouts { + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { return result[i].LastSeen > result[j].LastSeen }) + return result +} +func (w *Watcher) Get(id string) (Rollout, bool) { + w.mu.RLock() + defer w.mu.RUnlock() + for _, item := range w.rollouts { + if item.ID == id { + return item, true + } + } + return Rollout{}, false +} +func (w *Watcher) Enabled() bool { return w != nil && w.enabled } +func (w *Watcher) Error() string { + if w == nil { + return "kubernetes integration is not configured" + } + w.mu.RLock() + defer w.mu.RUnlock() + return w.err +} + +func rolloutID(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:8]) +} +func metadataValue(deployment *appsv1.Deployment, keys ...string) string { + for _, key := range keys { + if value := deployment.Labels[key]; value != "" { + return value + } + if value := deployment.Annotations[key]; value != "" { + return value + } + if value := deployment.Spec.Template.Labels[key]; value != "" { + return value + } + if value := deployment.Spec.Template.Annotations[key]; value != "" { + return value + } + } + return "" +} +func deploymentImage(deployment *appsv1.Deployment) string { + if len(deployment.Spec.Template.Spec.Containers) == 0 { + return "" + } + return deployment.Spec.Template.Spec.Containers[0].Image +} +func desiredReplicas(deployment *appsv1.Deployment) int32 { + if deployment.Spec.Replicas == nil { + return 1 + } + return *deployment.Spec.Replicas +} + +func rolloutStatus(deployment *appsv1.Deployment) (phase, reason, message string, completed bool) { + phase = "progressing" + for _, condition := range deployment.Status.Conditions { + switch condition.Type { + case appsv1.DeploymentProgressing: + reason, message = condition.Reason, condition.Message + if condition.Status == corev1.ConditionFalse { + return "failed", reason, message, false + } + case appsv1.DeploymentAvailable: + if condition.Status == corev1.ConditionTrue && deployment.Status.UpdatedReplicas == desiredReplicas(deployment) && deployment.Status.AvailableReplicas >= desiredReplicas(deployment) { + return "completed", reason, message, true + } + } + } + return phase, reason, message, false +} diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go new file mode 100644 index 0000000..cd6ae83 --- /dev/null +++ b/internal/prometheus/client.go @@ -0,0 +1,103 @@ +// Package prometheus provides optional Prometheus range queries for rollout impact. +package prometheus + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Point struct { + Timestamp float64 `json:"timestamp"` + Value string `json:"value"` +} + +type Client struct { + baseURL string + queryTemplate string + httpClient *http.Client +} + +func NewFromEnv() *Client { + baseURL := strings.TrimRight(os.Getenv("OTEL_PROMETHEUS_URL"), "/") + if baseURL == "" { + return nil + } + query := os.Getenv("OTEL_PROMETHEUS_ERROR_QUERY") + if query == "" { + query = `sum(rate(http_server_requests_total{service_name=%q,http_response_status_code=~"5.."}[5m]))` + } + return &Client{baseURL: baseURL, queryTemplate: query, httpClient: &http.Client{Timeout: 10 * time.Second}} +} + +func (c *Client) Configured() bool { return c != nil } + +func (c *Client) ErrorRate(ctx context.Context, service, release string, start, end time.Time) ([]Point, error) { + if c == nil { + return nil, fmt.Errorf("prometheus is not configured") + } + query := renderQuery(c.queryTemplate, service, release) + params := url.Values{ + "query": {query}, + "start": {strconv.FormatInt(start.Unix(), 10)}, + "end": {strconv.FormatInt(end.Unix(), 10)}, + "step": {"300"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/query_range?"+params.Encode(), nil) + if err != nil { + return nil, err + } + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode/100 != 2 { + return nil, fmt.Errorf("prometheus returned HTTP %s", response.Status) + } + var payload struct { + Status string `json:"status"` + Error string `json:"error"` + Data struct { + Result []struct { + Values [][]json.RawMessage `json:"values"` + } `json:"result"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return nil, err + } + if payload.Status != "success" { + return nil, fmt.Errorf("prometheus query failed: %s", payload.Error) + } + points := make([]Point, 0) + for _, series := range payload.Data.Result { + for _, value := range series.Values { + if len(value) != 2 { + continue + } + var timestamp float64 + var sample string + if json.Unmarshal(value[0], ×tamp) == nil && json.Unmarshal(value[1], &sample) == nil { + points = append(points, Point{Timestamp: timestamp, Value: sample}) + } + } + } + return points, nil +} + +func renderQuery(template, service, release string) string { + values := []string{strconv.Quote(service), strconv.Quote(release)} + for _, value := range values { + if strings.Contains(template, "%q") { + template = strings.Replace(template, "%q", value, 1) + } + } + return template +} diff --git a/internal/sourcemaps/store.go b/internal/sourcemaps/store.go new file mode 100644 index 0000000..8d92862 --- /dev/null +++ b/internal/sourcemaps/store.go @@ -0,0 +1,106 @@ +package sourcemaps + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + sm "github.com/go-sourcemap/sourcemap" +) + +var frameRE = regexp.MustCompile(`(.*?)(https?://[^ )]+|[^ )]+):(\d+):(\d+)(\)?\s*)$`) + +type Resolver struct { + dir string + mu sync.RWMutex + maps map[string]*sm.Consumer +} + +func New(dir string) (*Resolver, error) { + if dir == "" { + dir = "./sourcemaps" + } + if err := os.MkdirAll(dir, 0750); err != nil { + return nil, err + } + return &Resolver{dir: dir, maps: make(map[string]*sm.Consumer)}, nil +} + +func key(release, source string) string { return release + "\x00" + normalize(source) } + +func normalize(source string) string { + source = strings.Split(source, "?")[0] + if i := strings.Index(source, "/assets/"); i >= 0 { + return source[i:] + } + return strings.TrimPrefix(source, "./") +} + +func (r *Resolver) Upload(release, source string, body io.Reader) error { + if strings.TrimSpace(release) == "" || strings.TrimSpace(source) == "" { + return fmt.Errorf("release and source are required") + } + b, err := io.ReadAll(io.LimitReader(body, 50<<20)) + if err != nil { + return err + } + consumer, err := sm.Parse(source+".map", b) + if err != nil { + return fmt.Errorf("invalid source map: %w", err) + } + name := filepath.Join(r.dir, safe(release)+"-"+safe(normalize(source))+".map") + if err := os.WriteFile(name, b, 0600); err != nil { + return err + } + r.mu.Lock() + r.maps[key(release, source)] = consumer + r.mu.Unlock() + return nil +} + +func safe(s string) string { + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("._-", r) { + return r + } + return '_' + }, s) +} + +func (r *Resolver) ResolveStack(release, stack string) string { + if release == "" || stack == "" { + return stack + } + r.mu.RLock() + defer r.mu.RUnlock() + lines := strings.Split(stack, "\n") + for i, line := range lines { + m := frameRE.FindStringSubmatch(line) + if m == nil { + continue + } + genLine, _ := strconv.Atoi(m[3]) + genCol, _ := strconv.Atoi(m[4]) + consumer := r.maps[key(release, m[2])] + if consumer == nil { + continue + } + source, name, srcLine, srcCol, ok := consumer.Source(genLine, genCol) + if !ok { + continue + } + label := source + if name != "" { + label += " (" + name + ")" + } + lines[i] = fmt.Sprintf("%s%s:%d:%d%s [mapped from %s:%d:%d]", m[1], label, srcLine, srcCol, m[5], m[2], genLine, genCol) + } + return strings.Join(lines, "\n") +} diff --git a/internal/storage/clickhouse.go b/internal/storage/clickhouse.go new file mode 100644 index 0000000..7a7bdf9 --- /dev/null +++ b/internal/storage/clickhouse.go @@ -0,0 +1,528 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + + "git.tornberg.me/go-otel/internal/websocket" +) + +// ClickHouseStore implements the Store interface backed by ClickHouse, so +// history search stays fast and unbounded (the FileStore rescans the whole +// log file on every query) and aggregate time-series - error trends, release +// comparisons - can be computed server-side instead of in the browser. +type ClickHouseStore struct { + conn driver.Conn + db string +} + +// ClickHouseConfig controls how the store connects. +type ClickHouseConfig struct { + Addr string // host:port, native protocol (default port 9000) + Database string // defaults to "otel" + Username string // defaults to "default" + Password string + MaxOpenConns int // defaults to 50 + MaxIdleConns int // defaults to 25 + DialTimeout time.Duration // defaults to 30s (controls connection acquire timeout in driver pool) + ConnMaxLifetime time.Duration // defaults to 10m +} + +func parseEnvInt(keys []string, def int) int { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + } + return def +} + +func parseEnvDuration(keys []string, def time.Duration) time.Duration { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + } + } + return def +} + +// NewClickHouseStore connects, creates the schema if needed, and returns a +// ready store. Non-table errors during schema init are logged, not fatal: +// raw storage works even if the rollup view can't be created. +func NewClickHouseStore(ctx context.Context, cfg ClickHouseConfig) (*ClickHouseStore, error) { + if cfg.Database == "" { + cfg.Database = "otel" + } + if cfg.Username == "" { + cfg.Username = "default" + } + + maxOpen := cfg.MaxOpenConns + if maxOpen <= 0 { + maxOpen = parseEnvInt([]string{"OTEL_CLICKHOUSE_MAX_OPEN_CONNS", "CLICKHOUSE_MAX_OPEN_CONNS"}, 50) + } + maxIdle := cfg.MaxIdleConns + if maxIdle <= 0 { + maxIdle = parseEnvInt([]string{"OTEL_CLICKHOUSE_MAX_IDLE_CONNS", "CLICKHOUSE_MAX_IDLE_CONNS"}, 25) + } + dialTimeout := cfg.DialTimeout + if dialTimeout <= 0 { + dialTimeout = parseEnvDuration([]string{"OTEL_CLICKHOUSE_DIAL_TIMEOUT", "CLICKHOUSE_DIAL_TIMEOUT"}, 30*time.Second) + } + connMaxLifetime := cfg.ConnMaxLifetime + if connMaxLifetime <= 0 { + connMaxLifetime = parseEnvDuration([]string{"OTEL_CLICKHOUSE_CONN_MAX_LIFETIME", "CLICKHOUSE_CONN_MAX_LIFETIME"}, 10*time.Minute) + } + + // 1. Connect to default database with short timeout to ensure the target database exists. + bootConn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{cfg.Addr}, + Auth: clickhouse.Auth{ + Database: "default", + Username: cfg.Username, + Password: cfg.Password, + }, + DialTimeout: 5 * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("clickhouse dial %s: %w", cfg.Addr, err) + } + if err := bootConn.Ping(ctx); err != nil { + _ = bootConn.Close() + return nil, fmt.Errorf("clickhouse ping %s: %w", cfg.Addr, err) + } + + if err := bootConn.Exec(ctx, + fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", cfg.Database)); err != nil { + _ = bootConn.Close() + return nil, fmt.Errorf("clickhouse create database %s: %w", cfg.Database, err) + } + // Close bootstrap connection so it does not leak idle connections. + _ = bootConn.Close() + + // 2. Open production connection pool targeting the actual database with full pool settings. + conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{cfg.Addr}, + Auth: clickhouse.Auth{ + Database: cfg.Database, + Username: cfg.Username, + Password: cfg.Password, + }, + MaxOpenConns: maxOpen, + MaxIdleConns: maxIdle, + DialTimeout: dialTimeout, + ConnMaxLifetime: connMaxLifetime, + ConnOpenStrategy: clickhouse.ConnOpenRoundRobin, + ClientInfo: clickhouse.ClientInfo{ + Products: []struct { + Name string + Version string + }{ + {Name: "go-otel", Version: "1.0"}, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("clickhouse open %s/%s: %w", cfg.Addr, cfg.Database, err) + } + if err := conn.Ping(ctx); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("clickhouse ping %s/%s: %w", cfg.Addr, cfg.Database, err) + } + + s := &ClickHouseStore{conn: conn, db: cfg.Database} + if err := s.initSchema(ctx); err != nil { + _ = conn.Close() + return nil, err + } + return s, nil +} + +// initSchema creates the raw entries table and the materialized hourly +// rollup used by the stats endpoints. Idempotent. +func (s *ClickHouseStore) initSchema(ctx context.Context) error { + if err := s.conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.entries ( + timestamp DateTime64(9, 'UTC') NOT NULL, + type LowCardinality(String) NOT NULL, + service LowCardinality(String) NOT NULL DEFAULT 'unknown', + is_error UInt8 NOT NULL DEFAULT 0, + release LowCardinality(String) NOT NULL DEFAULT '', + trace_id String NOT NULL DEFAULT '', + data String NOT NULL + ) ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (type, timestamp) + TTL toDateTime(timestamp) + INTERVAL 90 DAY`, s.db)); err != nil { + return fmt.Errorf("clickhouse create entries: %w", err) + } + + // Hourly per-service/release rollup maintained on insert. is_error is a + // dimension (0/1), so errors = sum(count) WHERE is_error=1 and + // total = sum(count) unfiltered. + if err := s.conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.hourly ( + hour DateTime('UTC') NOT NULL, + type LowCardinality(String) NOT NULL, + service LowCardinality(String) NOT NULL, + release LowCardinality(String) NOT NULL DEFAULT '', + is_error UInt8 NOT NULL DEFAULT 0, + count UInt64 + ) ENGINE = SummingMergeTree + ORDER BY (hour, type, service, release, is_error)`, s.db)); err != nil { + return fmt.Errorf("clickhouse create hourly: %w", err) + } + + mv := fmt.Sprintf(`CREATE MATERIALIZED VIEW IF NOT EXISTS %s.hourly_mv TO %s.hourly AS + SELECT + toStartOfHour(timestamp) AS hour, + type, + service, + release, + is_error, + count() AS count + FROM %s.entries + GROUP BY hour, type, service, release, is_error`, s.db, s.db, s.db) + if err := s.conn.Exec(ctx, mv); err != nil { + // Raw storage works without the rollup; stats endpoints will just be + // empty until this succeeds. + fmt.Printf("clickhouse: failed to create rollup view (stats endpoints stay empty): %v\n", err) + } + return nil +} + +// chPayload extracts rollup columns from the marshaled OTLP JSON. Field +// structs are deliberately minimal so unmarshal cost stays low. +type chPayload struct { + ResourceSpans []chResourceSpan `json:"resourceSpans"` + ResourceLogs []chResourceLog `json:"resourceLogs"` +} + +type chResourceSpan struct { + Resource chResource `json:"resource"` + ScopeSpans []struct { + Spans []chSpan `json:"spans"` + } `json:"scopeSpans"` +} + +type chResourceLog struct { + Resource chResource `json:"resource"` + ScopeLogs []struct { + LogRecords []chLogRecord `json:"logRecords"` + } `json:"scopeLogs"` +} + +type chResource struct { + Attributes []chAttr `json:"attributes"` +} + +type chAttr struct { + Key string `json:"key"` + Value struct { + StringValue string `json:"stringValue"` + } `json:"value"` +} + +type chSpan struct { + TraceID string `json:"traceId"` + Status struct { + Code int `json:"code"` + } `json:"status"` + Attributes []chAttr `json:"attributes"` +} + +type chLogRecord struct { + SeverityNumber int `json:"severityNumber"` + SeverityText string `json:"severityText"` + TraceID string `json:"traceId"` + Attributes []chAttr `json:"attributes"` +} + +// extractHead pulls (service, is_error, release, trace_id) out of a raw OTLP +// JSON payload for the rollup columns. Best-effort: on parse failure the +// entry is still stored with defaults. +func extractHead(data []byte) (service string, isErr bool, release, traceID string) { + service, release = "unknown", "" + var p chPayload + if err := json.Unmarshal(data, &p); err != nil { + return service, false, release, traceID + } + + attrString := func(attrs []chAttr, key string) string { + for _, a := range attrs { + if a.Key == key { + return a.Value.StringValue + } + } + return "" + } + + // Traces: error if any span has status code 2 (StatusCodeError). + for _, rs := range p.ResourceSpans { + if s := attrString(rs.Resource.Attributes, "service.name"); s != "" && service == "unknown" { + service = s + } + if r := attrString(rs.Resource.Attributes, "service.release"); r != "" && release == "" { + release = r + } + for _, ss := range rs.ScopeSpans { + for _, span := range ss.Spans { + if span.TraceID != "" && traceID == "" { + traceID = span.TraceID + } + if span.Status.Code == 2 { + isErr = true + } + if r := attrString(span.Attributes, "service.release"); r != "" && release == "" { + release = r + } + } + } + } + + // Logs: error if severity number >= 17 (Error) or text is error-ish. + for _, rl := range p.ResourceLogs { + if s := attrString(rl.Resource.Attributes, "service.name"); s != "" && service == "unknown" { + service = s + } + if r := attrString(rl.Resource.Attributes, "service.release"); r != "" && release == "" { + release = r + } + for _, sl := range rl.ScopeLogs { + for _, rec := range sl.LogRecords { + if rec.TraceID != "" && traceID == "" { + traceID = rec.TraceID + } + if r := attrString(rec.Attributes, "service.release"); r != "" && release == "" { + release = r + } + if rec.SeverityNumber >= 17 { + isErr = true + } + switch strings.ToUpper(strings.TrimSpace(rec.SeverityText)) { + case "ERROR", "FATAL", "CRITICAL": + isErr = true + } + } + } + } + return service, isErr, release, traceID +} + +// Append stores one history entry with its extracted rollup columns. +// AsyncInsert lets ClickHouse batch the writes server-side. The severity +// column is intentionally omitted: one payload can hold many records with +// differing severities, so error-ness is a per-entry flag instead. +func (s *ClickHouseStore) Append(ctx context.Context, entry websocket.HistoryEntry) error { + service, isErr, release, traceID := extractHead(entry.Data) + + ts := time.Unix(0, entry.Timestamp).UTC() + err := s.conn.AsyncInsert(ctx, fmt.Sprintf( + `INSERT INTO %s.entries + (timestamp, type, service, is_error, release, trace_id, data) + VALUES (?, ?, ?, ?, ?, ?, ?)`, s.db), + false, + ts, entry.Type, service, boolToUint8(isErr), release, traceID, string(entry.Data), + ) + if err != nil { + return fmt.Errorf("clickhouse append: %w", err) + } + return nil +} + +func boolToUint8(b bool) uint8 { + if b { + return 1 + } + return 0 +} + +// GetHistory returns the most recent limit entries, newest first. +func (s *ClickHouseStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) { + return s.Search(ctx, SearchOptions{}, limit) +} + +// Search implements the same semantics as FileStore.Search: case-insensitive +// substring query over the raw payload, type/time filters, newest first, +// capped at limit. +func (s *ClickHouseStore) Search(ctx context.Context, opts SearchOptions, limit int) ([]websocket.HistoryEntry, error) { + if limit <= 0 { + limit = 50 + } + + where := []string{"1=1"} + args := []interface{}{} + if opts.Query != "" { + where = append(where, "positionCaseInsensitive(data, ?) > 0") + args = append(args, opts.Query) + } + if opts.Type != "" { + where = append(where, "type = ?") + args = append(args, opts.Type) + } + if opts.Since != 0 { + where = append(where, "timestamp >= ?") + args = append(args, time.Unix(0, opts.Since).UTC()) + } + if opts.Until != 0 { + where = append(where, "timestamp <= ?") + args = append(args, time.Unix(0, opts.Until).UTC()) + } + + query := fmt.Sprintf( + `SELECT timestamp, type, data + FROM %s.entries + WHERE %s + ORDER BY timestamp DESC + LIMIT ?`, s.db, strings.Join(where, " AND ")) + args = append(args, limit) + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("clickhouse search: %w", err) + } + defer rows.Close() + + var results []websocket.HistoryEntry + for rows.Next() { + var ( + ts time.Time + typ string + raw string + ) + if err := rows.Scan(&ts, &typ, &raw); err != nil { + return nil, err + } + results = append(results, websocket.HistoryEntry{ + Type: typ, + Timestamp: ts.UnixNano(), + Data: json.RawMessage(raw), + }) + } + return results, rows.Err() +} + +// StatsPoint is one bucket of a stats time-series. +type StatsPoint struct { + Bucket string `json:"bucket"` + Total uint64 `json:"total"` + Errors uint64 `json:"errors"` +} + +// ErrorStats returns hourly (or daily) total/error entry counts over the +// lookback window, for the trends graphs. Empty buckets are absent; the UI +// fills gaps for display. +func (s *ClickHouseStore) ErrorStats(ctx context.Context, service string, hours int, daily bool) ([]StatsPoint, error) { + bucket := "toStartOfHour(hour)" + if daily { + bucket = "toStartOfDay(hour)" + } + where := []string{"hour >= ?"} + args := []interface{}{time.Now().UTC().Add(-time.Duration(hours) * time.Hour)} + if service != "" { + where = append(where, "service = ?") + args = append(args, service) + } + + query := fmt.Sprintf( + `SELECT + toString(%s) AS bucket, + sum(count) AS total, + sumIf(count, is_error = 1) AS errors + FROM %s.hourly + WHERE %s + GROUP BY bucket + ORDER BY bucket`, bucket, s.db, strings.Join(where, " AND ")) + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("clickhouse error stats: %w", err) + } + defer rows.Close() + + var out []StatsPoint + for rows.Next() { + var p StatsPoint + if err := rows.Scan(&p.Bucket, &p.Total, &p.Errors); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ReleaseSeries returns one error-count series per release: +// {release -> [{bucket, total, errors}]}, so the UI can overlay deploys. +func (s *ClickHouseStore) ReleaseSeries(ctx context.Context, service string, hours int) (map[string][]StatsPoint, error) { + where := []string{"hour >= ?", "release != ''"} + args := []interface{}{time.Now().UTC().Add(-time.Duration(hours) * time.Hour)} + if service != "" { + where = append(where, "service = ?") + args = append(args, service) + } + + query := fmt.Sprintf( + `SELECT + release, + toString(toStartOfHour(hour)) AS bucket, + sum(count) AS total, + sumIf(count, is_error = 1) AS errors + FROM %s.hourly + WHERE %s + GROUP BY release, bucket + ORDER BY bucket`, s.db, strings.Join(where, " AND ")) + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("clickhouse release series: %w", err) + } + defer rows.Close() + + out := map[string][]StatsPoint{} + for rows.Next() { + var ( + release string + p StatsPoint + ) + if err := rows.Scan(&release, &p.Bucket, &p.Total, &p.Errors); err != nil { + return nil, err + } + out[release] = append(out[release], p) + } + return out, rows.Err() +} + +// Services returns the distinct services seen in the rollup, for filter UIs. +func (s *ClickHouseStore) Services(ctx context.Context) ([]string, error) { + rows, err := s.conn.Query(ctx, fmt.Sprintf( + `SELECT DISTINCT service FROM %s.hourly ORDER BY service`, s.db)) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var svc string + if err := rows.Scan(&svc); err != nil { + return nil, err + } + out = append(out, svc) + } + return out, rows.Err() +} + +// Close closes the underlying connection. +func (s *ClickHouseStore) Close() error { + return s.conn.Close() +} diff --git a/internal/storage/clickhouse_test.go b/internal/storage/clickhouse_test.go new file mode 100644 index 0000000..3e1958f --- /dev/null +++ b/internal/storage/clickhouse_test.go @@ -0,0 +1,121 @@ +package storage + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "git.tornberg.me/go-otel/internal/websocket" +) + +// TestClickHouseStoreIntegration is skipped unless OTEL_CLICKHOUSE_TEST_ADDR +// points at a disposable ClickHouse (native port 9000). It exercises the real +// SQL: schema creation, Append, rollup-triggered stats and Search filters. +// +// Placeholder round-trips through AsyncInsert use wait_for_async_insert so +// reads below see the writes without sleeps. +func TestClickHouseStoreIntegration(t *testing.T) { + addr := os.Getenv("OTEL_CLICKHOUSE_TEST_ADDR") + if addr == "" { + t.Skip("set OTEL_CLICKHOUSE_TEST_ADDR=host:9000 (with a disposable DB) to run") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Unique database per run so repeated runs don't collide. + db := "otel_test_" + time.Now().UTC().Format("150405") + cfg := ClickHouseConfig{Addr: addr, Database: db, Username: os.Getenv("OTEL_CLICKHOUSE_TEST_USER"), Password: os.Getenv("OTEL_CLICKHOUSE_TEST_PASSWORD")} + s, err := NewClickHouseStore(ctx, cfg) + if err != nil { + t.Skipf("ClickHouse not reachable at %s: %v", addr, err) + } + defer func() { + _ = s.conn.Exec(ctx, "DROP DATABASE IF EXISTS "+db) + _ = s.Close() + }() + + now := time.Now().UTC() + mk := func(minsAgo int, typ, service string, isErr bool, release string) websocket.HistoryEntry { + raw, _ := json.Marshal(map[string]interface{}{"n": minsAgo}) + return websocket.HistoryEntry{ + Type: typ, + Timestamp: now.Add(-time.Duration(minsAgo) * time.Minute).UnixNano(), + Data: raw, + } + } + entries := []websocket.HistoryEntry{ + mk(200, "logs", "api", true, "v1.3"), + mk(190, "logs", "api", false, "v1.3"), + mk(100, "logs", "api", true, "v1.4"), + mk(90, "logs", "api", false, "v1.4"), + mk(30, "logs", "api", false, "v1.4"), + } + for i, e := range entries { + if err := s.Append(ctx, e); err != nil { + t.Fatalf("Append[%d]: %v", i, err) + } + } + // Flush async insert buffer before reading. + if err := s.conn.AsyncInsert(ctx, "INSERT INTO FUNCTION null('x UInt8') VALUES (0)", true); err != nil { + t.Fatalf("flush: %v", err) + } + + // Search: newest first, limit respected. + res, err := s.Search(ctx, SearchOptions{Type: "logs"}, 3) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(res) != 3 { + t.Fatalf("expected 3 search results, got %d", len(res)) + } + if res[0].Timestamp < res[2].Timestamp { + t.Errorf("expected newest first") + } + + // Search: substring filter. + res, err = s.Search(ctx, SearchOptions{Query: "\"n\":200"}, 10) + if err != nil { + t.Fatalf("Search query: %v", err) + } + if len(res) != 1 { + t.Errorf("expected substring filter to match 1 entry, got %d", len(res)) + } + + // ErrorStats via the rollup MV: two hours with data, 2 errors total. + points, err := s.ErrorStats(ctx, "api", 24, false) + if err != nil { + t.Fatalf("ErrorStats: %v", err) + } + errSum, totSum := uint64(0), uint64(0) + for _, p := range points { + errSum += p.Errors + totSum += p.Total + } + if errSum != 2 || totSum != 5 { + t.Errorf("expected rollup totals errors=2 total=5, got errors=%d total=%d", errSum, totSum) + } + + // ReleaseSeries: v1.3 has 2 entries (1 error), v1.4 has 3 (1 error). + series, err := s.ReleaseSeries(ctx, "api", 24) + if err != nil { + t.Fatalf("ReleaseSeries: %v", err) + } + var v13, v14 uint64 + for _, p := range series["v1.3"] { + v13 += p.Total + } + for _, p := range series["v1.4"] { + v14 += p.Total + } + if v13 != 2 || v14 != 3 { + t.Errorf("expected per-release totals v1.3=2 v1.4=3, got %d/%d", v13, v14) + } + + // Services. + svcs, err := s.Services(ctx) + if err != nil || len(svcs) == 0 || svcs[0] != "api" { + t.Errorf("expected services [api], got %v (err=%v)", svcs, err) + } +} diff --git a/internal/storage/store.go b/internal/storage/store.go index be1526d..9a7766b 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -26,3 +26,15 @@ type Store interface { GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) Search(ctx context.Context, opts SearchOptions, limit int) ([]websocket.HistoryEntry, error) } + +// StatsQuerier is implemented by stores that can answer aggregate +// time-series questions (currently ClickHouseStore). The stats HTTP +// endpoints are only enabled when the configured store implements it. +type StatsQuerier interface { + // ErrorStats returns hourly/daily total and error entry counts. + ErrorStats(ctx context.Context, service string, hours int, daily bool) ([]StatsPoint, error) + // ReleaseSeries returns per-release error-count time series. + ReleaseSeries(ctx context.Context, service string, hours int) (map[string][]StatsPoint, error) + // Services lists distinct services seen in the rollup. + Services(ctx context.Context) ([]string, error) +} diff --git a/internal/websocket/server.go b/internal/websocket/server.go index 9348579..dff7dfc 100644 --- a/internal/websocket/server.go +++ b/internal/websocket/server.go @@ -38,6 +38,28 @@ type Entry struct { Data json.RawMessage `json:"data"` } +// issuesNotification is broadcast when new error telemetry has been ingested, +// so UI clients can refresh their Issues view immediately. It deliberately +// carries no payload - clients re-fetch GET /api/issues. +type issuesNotification struct { + Type string `json:"type"` // always "issues" +} + +// NotifyIssues broadcasts an issues-refresh hint to all connected clients. +func (s *Server) NotifyIssues() { + msg, err := json.Marshal(issuesNotification{Type: "issues"}) + if err != nil { + return + } + s.mutex.Lock() + defer s.mutex.Unlock() + for client := range s.clients { + if err := client.WriteMessage(websocket.TextMessage, msg); err != nil { + log.Printf("write error: %v", err) + } + } +} + // Broadcaster interface for sending telemetry updates to all connected clients. type Broadcaster interface { Broadcast(data *Entry) diff --git a/k8s/clickhouse.yaml b/k8s/clickhouse.yaml new file mode 100644 index 0000000..5401615 --- /dev/null +++ b/k8s/clickhouse.yaml @@ -0,0 +1,134 @@ +# Single-node ClickHouse for the otel-debug-receiver, applied as one file: +# kubectl apply -f k8s/clickhouse.yaml +# +# A single instance (per request): one StatefulSet replica with a PVC so +# telemetry survives pod restarts. Resources sized for a small cluster; +# adjust before production use. +apiVersion: v1 +kind: Namespace +metadata: + name: otel-debug +--- +apiVersion: v1 +kind: Secret +metadata: + name: clickhouse-auth + namespace: monitoring +type: Opaque +stringData: + # Change these before applying. ClickHouse will honor the values from + # the users.d override mounted below, so no post-install SQL is needed. + username: otel + password: CHANGE_ME +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: clickhouse-config + namespace: monitoring +data: + users.d-otel.xml: | + + + + + ::/0 + + GRANT ALL ON otel.* + + + + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: clickhouse + namespace: monitoring +spec: + serviceName: clickhouse + replicas: 1 + selector: + matchLabels: + app: clickhouse + template: + metadata: + labels: + app: clickhouse + spec: + securityContext: + fsGroup: 101 + containers: + - name: clickhouse + image: clickhouse/clickhouse-server:24.8 + ports: + - containerPort: 9000 + name: native + - containerPort: 8123 + name: http + env: + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: clickhouse-auth + key: password + volumeMounts: + - name: data + mountPath: /var/lib/clickhouse + # Mount the user override as a single file: ConfigMap volumes are + # read-only, and the entrypoint needs to write its own + # default-user.xml into the users.d directory on first start. + - name: user-config + mountPath: /etc/clickhouse-server/users.d/otel.xml + subPath: users.d-otel.xml + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "1" + volumes: + - name: user-config + configMap: + name: clickhouse-config + items: + - key: users.d-otel.xml + path: users.d-otel.xml + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: clickhouse + namespace: monitoring +spec: + selector: + app: clickhouse + ports: + - name: native + port: 9000 + targetPort: 9000 + - name: http + port: 8123 + targetPort: 8123 + type: ClusterIP diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 96e31cb..734a26b 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -2,6 +2,10 @@ apiVersion: apps/v1 kind: Deployment metadata: name: otel-debug-receiver + namespace: monitoring + annotations: + k8s-helper.io/git-ssh: git@git.k6n.net:mats/go-otel.git + k8s-helper.io/git-url: https://git.k6n.net/mats/go-otel spec: replicas: 1 selector: @@ -12,6 +16,7 @@ spec: labels: app: otel-debug-receiver spec: + serviceAccountName: otel-debug-receiver securityContext: runAsNonRoot: true runAsUser: 1000 @@ -40,9 +45,77 @@ spec: value: "info" - name: OTELLOG_PATH value: "/logs/otel-agent.log" + - name: OTEL_SOURCEMAP_DIR + value: "/sourcemaps" + - name: OTEL_OIDC_ISSUER + value: "https://users.k6n.net" + - name: OTEL_OIDC_CLIENT_ID + value: "otel-debug" + # Service credential for non-interactive debugging agents. It grants + # access to protected /api debug endpoints, not OTLP ingest. + - name: OTEL_AGENT_TOKEN + valueFrom: + secretKeyRef: + name: otel-agent-auth + key: token + # The debug API must not start without its service credential. + # Leaving this optional makes the pod appear healthy while every + # protected API request can only return 401. + optional: false + # ClickHouse storage: enables the aggregate stats endpoints + # (/api/stats/*). Falls back to the file store if unreachable. + - name: OTEL_CLICKHOUSE_ADDR + value: "clickhouse.monitoring.svc.cluster.local:9000" + - name: OTEL_CLICKHOUSE_DB + value: "otel" + - name: OTEL_CLICKHOUSE_USER + valueFrom: + secretKeyRef: + name: clickhouse-auth + key: username + optional: true + - name: OTEL_CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: clickhouse-auth + key: password + optional: true + # Connection pool tuning to prevent "acquire conn timeout" under ingest bursts + - name: OTEL_CLICKHOUSE_MAX_OPEN_CONNS + value: "10" + - name: OTEL_CLICKHOUSE_MAX_IDLE_CONNS + value: "5" + - name: OTEL_CLICKHOUSE_DIAL_TIMEOUT + value: "30s" + - name: OTEL_CLICKHOUSE_CONN_MAX_LIFETIME + value: "10m" + - name: OTEL_STORE_WORKERS + value: "4" + # Optional rollout observation. The watcher is read-only and uses the + # service account below. Leave Prometheus unset when unavailable. + - name: OTEL_KUBERNETES_NAMESPACE + value: "" + # - name: OTEL_PROMETHEUS_URL + # value: "http://prometheus.monitoring.svc.cluster.local:9090" + # Private token for an ingress-side synchronizer. If absent, the + # promoted-IP endpoint stays disabled. + - name: OTEL_HONEYPOT_TOKEN + valueFrom: + secretKeyRef: + name: honeypot-auth + key: token + optional: true + - name: OTEL_HONEYPOT_THRESHOLD + value: "3" + - name: OTEL_HONEYPOT_WINDOW + value: "15m" + - name: OTEL_HONEYPOT_TTL + value: "1h" volumeMounts: - name: log-dir mountPath: /logs + - name: sourcemap-dir + mountPath: /sourcemaps livenessProbe: httpGet: path: /health @@ -65,3 +138,39 @@ spec: volumes: - name: log-dir emptyDir: {} + - name: sourcemap-dir + emptyDir: {} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: otel-debug-receiver + namespace: monitoring + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: otel-debug-receiver-rollout-reader +rules: +- apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch"] +- apiGroups: [""] + resources: ["pods", "events"] + verbs: ["get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: otel-debug-receiver-rollout-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: otel-debug-receiver-rollout-reader +subjects: +- kind: ServiceAccount + name: otel-debug-receiver + namespace: monitoring diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index d212898..6cb9f29 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -1,11 +1,99 @@ apiVersion: networking.k8s.io/v1 kind: Ingress +metadata: + name: otel-debug-ingress-public + namespace: monitoring + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - otel.k6n.net + secretName: otel-k6n-tls + rules: + - host: otel.k6n.net + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /v1 + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /health + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 + # The handler has its own static token authentication. This route + # lets an in-cluster ingress synchronizer fetch promoted IPs without + # requiring a browser OIDC session. + - path: /internal/honeypot + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /ws + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /agents_example.md + pathType: ImplementationSpecific + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /instrumentation.md + pathType: ImplementationSpecific + backend: + service: + name: otel-debug-service + port: + number: 80 + - path: /static + pathType: Prefix + backend: + service: + name: otel-debug-service + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress metadata: name: otel-debug-ingress + namespace: monitoring annotations: cert-manager.io/cluster-issuer: letsencrypt-prod + # OIDC protection via the platform auth service (users.k6n.net): + # /oauth2/auth resolves the session cookie / bearer token and answers + # 200 (allow), 401 (challenge -> browser_redirect to /login) or 403. + # otel.k6n.net is registered as a realm host in the auth service's + # realms.json. Public API and agent endpoints are routed via otel-debug-ingress-public. + nginx.ingress.kubernetes.io/auth-url: https://users.k6n.net/oauth2/auth + nginx.ingress.kubernetes.io/auth-signin: https://users.k6n.net/login?rd=$scheme://$host$escaped_request_uri + nginx.ingress.kubernetes.io/auth-response-headers: X-Auth-User + nginx.ingress.kubernetes.io/auth-cache-duration: 200 60s, 401 5s, 403 5s + nginx.ingress.kubernetes.io/auth-proxy-set-headers: monitoring/auth-subrequest-headers spec: - ingressClassName: nginx # Assuming nginx ingress controller + ingressClassName: nginx tls: - hosts: - otel.k6n.net @@ -21,3 +109,13 @@ spec: name: otel-debug-service port: number: 80 +--- +# Header map for the auth subrequest (same pattern as home/auth-subrequest-headers). +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-subrequest-headers + namespace: monitoring +data: + X-Original-Method: $request_method + X-Original-URL: $scheme://$host$request_uri diff --git a/otel-ui/.gitignore b/otel-ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/otel-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/otel-ui/README.md b/otel-ui/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/otel-ui/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/otel-ui/index.html b/otel-ui/index.html new file mode 100644 index 0000000..87bf682 --- /dev/null +++ b/otel-ui/index.html @@ -0,0 +1,20 @@ + + + + + + + HyperScope OTel — Distributed Telemetry + + + + + +
+ + + + diff --git a/otel-ui/package-lock.json b/otel-ui/package-lock.json new file mode 100644 index 0000000..3c8ef3b --- /dev/null +++ b/otel-ui/package-lock.json @@ -0,0 +1,5917 @@ +{ + "name": "otel-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "otel-ui", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-accordion": "1.2.2", + "@radix-ui/react-alert-dialog": "1.1.4", + "@radix-ui/react-aspect-ratio": "1.1.1", + "@radix-ui/react-avatar": "1.1.2", + "@radix-ui/react-checkbox": "1.1.3", + "@radix-ui/react-collapsible": "1.1.2", + "@radix-ui/react-context-menu": "2.2.4", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-hover-card": "1.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-menubar": "1.1.4", + "@radix-ui/react-navigation-menu": "1.2.3", + "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "1.1.1", + "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "1.2.2", + "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-separator": "1.1.1", + "@radix-ui/react-slider": "1.2.2", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-switch": "1.1.2", + "@radix-ui/react-tabs": "1.1.2", + "@radix-ui/react-toast": "1.2.4", + "@radix-ui/react-toggle": "1.1.1", + "@radix-ui/react-toggle-group": "1.1.1", + "@radix-ui/react-tooltip": "1.1.6", + "@tailwindcss/postcss": "^4.1.16", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-virtual": "^3.14.13", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.8.1", + "swr": "^2.5.1", + "tailwind-merge": "^2.5.5", + "wouter": "^3.11.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "biome": "^0.3.3", + "globals": "^16.4.0", + "lucide-react": "^0.454.0", + "postcss": "^8.5", + "tailwindcss": "^4.1.17", + "tw-animate-css": "1.3.3", + "typescript": "~5.9.3", + "vite": "^7.1.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.2.tgz", + "integrity": "sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.2", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz", + "integrity": "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.1.tgz", + "integrity": "sha512-kNU4FIpcFMBLkOUcgeIteH06/8JLBcYY6Le1iKenDGCYNYFX3TQqCZjzkOsz37h7r94/99GTb7YhEr98ZBJibw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", + "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", + "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.2.tgz", + "integrity": "sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.4.tgz", + "integrity": "sha512-ap4wdGwK52rJxGkwukU1NrnEodsUFQIooANKu+ey7d6raQ2biTcEf8za1zr0mgFHieevRTB2nK4dJeN8pTAZGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", + "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz", + "integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz", + "integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", + "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz", + "integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.4.tgz", + "integrity": "sha512-+KMpi7VAZuB46+1LD7a30zb5IxyzLgC8m8j42gk3N4TUCcViNQdX8FhoH1HDvYiA8quuqcek4R4bYpPn/SY1GA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz", + "integrity": "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz", + "integrity": "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", + "integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", + "integrity": "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", + "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", + "integrity": "sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz", + "integrity": "sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.2.tgz", + "integrity": "sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz", + "integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz", + "integrity": "sha512-Sch9idFJHJTMH9YNpxxESqABcAFweJG4tKv+0zo0m5XBvUSL8FM5xKcJLFLXononpePs8IclyX1KieL5SDUNgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.1.tgz", + "integrity": "sha512-i77tcgObYr743IonC1hrsnnPmszDRn8p+EGUsUt+5a/JFn28fxaM88Py6V2mc8J5kELMWishI0rLnuGLFD/nnQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.1.tgz", + "integrity": "sha512-OgDLZEA30Ylyz8YSXvnGqIHtERqnUt1KUYTKdw/y8u7Ci6zGiJfXc02jahmcSNK3YcErqioj/9flWC9S1ihfwg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-toggle": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", + "integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.16" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz", + "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "postcss": "^8.4.41", + "tailwindcss": "4.1.16" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "license": "MIT" + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz", + "integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz", + "integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", + "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/biome": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/biome/-/biome-0.3.3.tgz", + "integrity": "sha512-4LXjrQYbn9iTXu9Y4SKT7ABzTV0WnLDHCVSd2fPUOKsy1gQ+E4xPFmlY1zcWexoi0j7fGHItlL6OWA2CZ/yYAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "bluebird": "^3.4.1", + "chalk": "^1.1.3", + "commander": "^2.9.0", + "editor": "^1.0.0", + "fs-promise": "^0.5.0", + "inquirer-promise": "0.0.3", + "request-promise": "^3.0.0", + "untildify": "^3.0.2", + "user-home": "^2.0.0" + }, + "bin": { + "biome": "dist/index.js" + } + }, + "node_modules/biome/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/biome/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/biome/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/biome/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cli-width": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-1.1.1.tgz", + "integrity": "sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg==", + "dev": true, + "license": "ISC" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/earlgrey-runtime": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/earlgrey-runtime/-/earlgrey-runtime-0.1.2.tgz", + "integrity": "sha512-T4qoScXi5TwALDv8nlGTvOuCT8jXcKcxtO8qVdqv46IA2GHJfQzwoBPbkOmORnyhu3A98cVVuhWLsM2CzPljJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js": "^2.4.0", + "kaiser": ">=0.0.4", + "lodash": "^4.17.2", + "regenerator-runtime": "^0.9.5" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/editor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/editor/-/editor-1.0.0.tgz", + "integrity": "sha512-SoRmbGStwNYHgKfjOrX2L0mUvp9bUVv0uPppZSOMAntEbcFtoC3MKF5b3T6HQPXKIV+QGY3xPO3JK5it5lVkuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.245", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.245.tgz", + "integrity": "sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/fs-extra": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.26.7.tgz", + "integrity": "sha512-waKu+1KumRhYv8D8gMRCKJGAMI9pRnPuEb1mvgYD0f7wBscg+h6bW4FDTmEZhB9VKxvoTtxW+Y7bnIlB7zja6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0", + "path-is-absolute": "^1.0.0", + "rimraf": "^2.2.8" + } + }, + "node_modules/fs-promise": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/fs-promise/-/fs-promise-0.5.0.tgz", + "integrity": "sha512-Y+4F4ujhEcayCJt6JmzcOun9MYGQwz+bVUiuBmTkJImhBHKpBvmVPZR9wtfiF7k3ffwAOAuurygQe+cPLSFQhw==", + "deprecated": "Use mz or fs-extra^3.0 with Promise Support", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "fs-extra": "^0.26.5", + "mz": "^2.3.1", + "thenify-all": "^1.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.11.4.tgz", + "integrity": "sha512-QR+2TW90jnKk9LUUtbcA3yQXKt2rDEKMh6+BAZQIeumtzHexnwVLdPakSslGijXYLJCzFv7GMXbFCn0pA00EUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^1.1.0", + "ansi-regex": "^2.0.0", + "chalk": "^1.0.0", + "cli-cursor": "^1.0.1", + "cli-width": "^1.0.1", + "figures": "^1.3.5", + "lodash": "^3.3.1", + "readline2": "^1.0.1", + "run-async": "^0.1.0", + "rx-lite": "^3.1.2", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + } + }, + "node_modules/inquirer-promise": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/inquirer-promise/-/inquirer-promise-0.0.3.tgz", + "integrity": "sha512-82CQX586JAV9GAgU9yXZsMDs+NorjA0nLhkfFx9+PReyOnuoHRbHrC1Z90sS95bFJI1Tm1gzMObuE0HabzkJpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "earlgrey-runtime": ">=0.0.11", + "inquirer": "^0.11.3" + } + }, + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inquirer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/inquirer/node_modules/lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/inquirer/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/kaiser": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/kaiser/-/kaiser-0.0.4.tgz", + "integrity": "sha512-m8ju+rmBqvclZmyrOXgGGhOYSjKJK6RN1NhqEltemY87UqZOxEkizg9TOy1vQSyJ01Wx6SAPuuN0iO2Mgislvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "earlgrey-runtime": ">=0.0.10" + } + }, + "node_modules/klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha512-TED5xi9gGQjGpNnvRWknrwAB1eL5GciPfVFOt3Vk1OJCVDQbzuSfrF3hkUQKlsgKrG1F+0t5W0m+Fje1jIt8rw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.9" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg==", + "dev": true, + "license": "ISC" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "mute-stream": "0.0.5" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz", + "integrity": "sha512-D0Y/JJ4VhusyMOd/o25a3jdUqN/bC85EFsaoL9Oqmy/O4efCh+xhp7yj2EEOsj974qvMkcW8AwUzJ1jB/MbxCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-3.0.0.tgz", + "integrity": "sha512-wVGUX+BoKxYsavTA72i6qHcyLbjzM4LR4y/AmDCqlbuMAursZdDWO7PmgbGAUvD2SeEJ5iB99VSq/U51i/DNbw==", + "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.3", + "lodash": "^4.6.1", + "request": "^2.34" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw==", + "dev": true, + "license": "MIT", + "dependencies": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha512-qOX+w+IxFgpUpJfkv2oGN0+ExPs68F4sZHfaRRx4dDexAQkG83atugKVEylyT5ARees3HBbfmuvnjbrd8j9Wjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.3.0" + } + }, + "node_modules/rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha512-1I1+G2gteLB8Tkt8YI1sJvSIfa0lWuRtC8GjvtyPBcLSF5jBCCJJqKrpER5JU5r6Bhe+i9/pK3VMuUcXu0kdwQ==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/swr": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.5.1.tgz", + "integrity": "sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tw-animate-css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.3.tgz", + "integrity": "sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/untildify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz", + "integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.1.tgz", + "integrity": "sha512-qTl3VF7BvOupTR85Zc561sPEgxyUSNSvTQ9fit7DEMP7yPgvvIGm5Zfa1dOM+kOwWGNviK9uFM9ra77+OjK7lQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wouter": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", + "integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==", + "license": "Unlicense", + "dependencies": { + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/otel-ui/package.json b/otel-ui/package.json new file mode 100644 index 0000000..30ad300 --- /dev/null +++ b/otel-ui/package.json @@ -0,0 +1,65 @@ +{ + "name": "otel-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-accordion": "1.2.2", + "@radix-ui/react-alert-dialog": "1.1.4", + "@radix-ui/react-aspect-ratio": "1.1.1", + "@radix-ui/react-avatar": "1.1.2", + "@radix-ui/react-checkbox": "1.1.3", + "@radix-ui/react-collapsible": "1.1.2", + "@radix-ui/react-context-menu": "2.2.4", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-hover-card": "1.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-menubar": "1.1.4", + "@radix-ui/react-navigation-menu": "1.2.3", + "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "1.1.1", + "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "1.2.2", + "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-separator": "1.1.1", + "@radix-ui/react-slider": "1.2.2", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-switch": "1.1.2", + "@radix-ui/react-tabs": "1.1.2", + "@radix-ui/react-toast": "1.2.4", + "@radix-ui/react-toggle": "1.1.1", + "@radix-ui/react-toggle-group": "1.1.1", + "@radix-ui/react-tooltip": "1.1.6", + "@tailwindcss/postcss": "^4.1.16", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-virtual": "^3.14.13", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.8.1", + "swr": "^2.5.1", + "tailwind-merge": "^2.5.5", + "wouter": "^3.11.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "biome": "^0.3.3", + "globals": "^16.4.0", + "lucide-react": "^0.454.0", + "postcss": "^8.5", + "tailwindcss": "^4.1.17", + "tw-animate-css": "1.3.3", + "typescript": "~5.9.3", + "vite": "^7.1.7" + } +} diff --git a/otel-ui/postcss.config.mjs b/otel-ui/postcss.config.mjs new file mode 100644 index 0000000..a869506 --- /dev/null +++ b/otel-ui/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} + +export default config diff --git a/otel-ui/public/vite.svg b/otel-ui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/otel-ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx new file mode 100644 index 0000000..50660f8 --- /dev/null +++ b/otel-ui/src/App.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect, useRef } from "react"; +import { useObservabilityData } from "./hooks/useObservabilityData"; +import { Sidebar } from "./components/Sidebar"; +import { TopHeader } from "./components/TopHeader"; +import { CommandAndQueryBar } from "./components/CommandAndQueryBar"; +import { WaveformHistogram } from "./components/WaveformHistogram"; +import { EntryVisualization } from "./components/EntryVisualization"; +import { handleCallback, login } from "./lib/auth"; + +function AuthCallbackView() { + const [error, setError] = useState(null); + const handledRef = useRef(false); + + useEffect(() => { + if (handledRef.current) return; + handledRef.current = true; + + handleCallback() + .then(() => { + window.location.href = "/"; + }) + .catch((err) => { + console.error("Authentication callback error:", err); + setError(err instanceof Error ? err.message : String(err)); + }); + }, []); + + return ( +
+
+ {error ? ( +
+
+ error +
+
+

Authentication Failed

+

{error}

+
+ +
+ ) : ( +
+
+ lock +
+
+

Completing sign in

+

Exchanging authorization code with OIDC provider…

+
+
+ )} +
+
+ ); +} + +function App() { + const { isConnected, traces, logs, metrics } = useObservabilityData(); + + const hasLoadedAnyData = traces.length > 0 || logs.length > 0 || metrics.length > 0; + const isCallback = + typeof window !== "undefined" && + (window.location.pathname === "/auth_callback" || + window.location.search.includes("code=")); + + if (isCallback) { + return ; + } + + return ( +
+ {/* Fixed Left Navigation Sidebar (w-72) */} + + + {/* Main Content Area offset by pl-72 */} +
+ {/* Fixed Top Header Bar */} + + + {/* Main Telemetry Console View */} +
+
+ {/* Real Anomaly Radar & Filter Bar */} + + + {/* Ingest & Incident Waveform Histogram */} + + + {/* Telemetry Visualizations & Views */} +
+ {isConnected || hasLoadedAnyData ? ( + + ) : ( +
+
+ + cloud_sync + +
+
+

+ Establishing telemetry pipeline +

+

+ Connecting to OTLP collector. Listening for incoming traces, logs, and metrics. +

+
+
+ )} +
+
+
+
+
+ ); + +} + +export default App; diff --git a/otel-ui/src/assets/react.svg b/otel-ui/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/otel-ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/otel-ui/src/components/CommandAndQueryBar.tsx b/otel-ui/src/components/CommandAndQueryBar.tsx new file mode 100644 index 0000000..b0bfb98 --- /dev/null +++ b/otel-ui/src/components/CommandAndQueryBar.tsx @@ -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(); + + 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 ( +
+ {/* Real Anomaly Radar Banner */} + {(errorStats.errorCount > 0 || topOpenIssue) && ( +
+
+
+ warning +
+
+
+ + Anomaly Radar: + + + {errorStats.errorCount > 0 + ? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})` + : `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`} + +
+ {topOpenIssue && ( + + Top Issue: {topOpenIssue.title} (Service: {topOpenIssue.service}, ×{topOpenIssue.count}) + + )} +
+
+ +
+ + {!showErrorsOnly && ( + + )} +
+
+ )} + + {/* Filter and Telemetry Status Bar */} +
+
+ + Active Context: + + + + {totalFilteredEvents} Signals ({timeWindow === "all" ? "All Time" : `Last ${timeWindow}`}) + + + {/* Selected Service Pills */} + {Array.from(selectedServices).map((service) => ( + + service:{service} + + + ))} + + {/* Errors Only Pill */} + {showErrorsOnly && ( + + Errors Only + + + )} + + {/* Slow Spans Pill */} + {minDurationMs > 0 && ( + + Latency >{minDurationMs}ms + + + )} + + {/* Search Query Pill */} + {searchQuery && ( + + query: "{searchQuery}" + + + )} + + {hasActiveFilters && ( + + )} +
+ + {/* Export and Action buttons */} +
+ +
+
+
+ ); +}; + +export default CommandAndQueryBar; diff --git a/otel-ui/src/components/EntryVisualization.tsx b/otel-ui/src/components/EntryVisualization.tsx new file mode 100644 index 0000000..ecca1ca --- /dev/null +++ b/otel-ui/src/components/EntryVisualization.tsx @@ -0,0 +1,9 @@ +import { TabsComponent } from "./Tabs"; + +export const EntryVisualization = () => { + return ( +
+ +
+ ); +}; diff --git a/otel-ui/src/components/FrontendVisualizer.tsx b/otel-ui/src/components/FrontendVisualizer.tsx new file mode 100644 index 0000000..64ef301 --- /dev/null +++ b/otel-ui/src/components/FrontendVisualizer.tsx @@ -0,0 +1,1239 @@ +import React, { useState, useMemo } from "react"; +import { useObservabilityData } from "../hooks/useObservabilityData"; +import { getAttributeValue, getServiceName } from "../lib/otel-utils"; +import { + Globe, + Monitor, + Smartphone, + AlertCircle, + WifiOff, + Activity, + Gauge, + ArrowRight, + ExternalLink, + Layers, + Search, + Sparkles, + Copy, + Check, + Code, + ChevronRight, + FileCode, + Radio, +} from "lucide-react"; + +// --- Types --- +export interface BreadcrumbItem { + t: number; + category: string; + message: string; +} + +export interface FrontendEvent { + id: string; + timestamp: number; + timeUnixNano: string; + body: string; + severityText: string; + severityNumber: number; + traceId?: string; + service: string; + + // RUM Specific Attributes + isVital: boolean; + vitalName?: string; + vitalValue?: number; + vitalElement?: string; + + url: string; + path: string; + userAgent: string; + viewport: string; + sessionId: string; + + // Errors + isError: boolean; + isHttpFailure: boolean; + exceptionType?: string; + exceptionMessage?: string; + stacktrace?: string; + sourceFile?: string; + sourceLine?: string; + sourceColumn?: string; + + // HTTP / Network + httpMethod?: string; + httpUrl?: string; + httpStatus?: number; + + // Breadcrumbs & Context + breadcrumbs: BreadcrumbItem[]; + rawAttributes: Record; +} + +// Google Core Web Vitals thresholds +interface VitalThreshold { + good: number; + needsImprovement: number; + unit: string; + description: string; + format: (v: number) => string; +} + +const VITAL_THRESHOLDS: Record = { + LCP: { + good: 2500, + needsImprovement: 4000, + unit: "ms", + description: "Largest Contentful Paint (Main content render speed)", + format: (v) => `${(v / 1000).toFixed(2)}s`, + }, + INP: { + good: 200, + needsImprovement: 500, + unit: "ms", + description: "Interaction to Next Paint (UI responsiveness)", + format: (v) => `${Math.round(v)}ms`, + }, + CLS: { + good: 0.1, + needsImprovement: 0.25, + unit: "", + description: "Cumulative Layout Shift (Visual stability)", + format: (v) => (Math.round(v * 1000) / 1000).toFixed(3), + }, + FCP: { + good: 1800, + needsImprovement: 3000, + unit: "ms", + description: "First Contentful Paint (First text or image paint)", + format: (v) => `${(v / 1000).toFixed(2)}s`, + }, + TTFB: { + good: 800, + needsImprovement: 1800, + unit: "ms", + description: "Time to First Byte (Server initial response)", + format: (v) => `${Math.round(v)}ms`, + }, +}; + +function getVitalRating(name: string, value: number): "good" | "needs-improvement" | "poor" { + const t = VITAL_THRESHOLDS[name]; + if (!t) return "good"; + if (value <= t.good) return "good"; + if (value <= t.needsImprovement) return "needs-improvement"; + return "poor"; +} + +function parseUserAgent(ua: string) { + let browser = "Browser"; + let os = "OS"; + let isMobile = /mobile|android|iphone|ipad/i.test(ua); + + if (/chrome|crios/i.test(ua) && !/edg|opr/i.test(ua)) browser = "Chrome"; + else if (/safari/i.test(ua) && !/chrome/i.test(ua)) browser = "Safari"; + else if (/firefox|fxios/i.test(ua)) browser = "Firefox"; + else if (/edg/i.test(ua)) browser = "Edge"; + else if (/opr/i.test(ua)) browser = "Opera"; + + if (/windows/i.test(ua)) os = "Windows"; + else if (/macintosh|mac os x/i.test(ua)) os = "macOS"; + else if (/android/i.test(ua)) os = "Android"; + else if (/iphone|ipad|ipod/i.test(ua)) os = "iOS"; + else if (/linux/i.test(ua)) os = "Linux"; + + return { browser, os, isMobile }; +} + +export const FrontendVisualizer: React.FC = () => { + const { logs, setActiveTab, setSearchQuery: setGlobalSearchQuery } = useObservabilityData(); + + const [activeSubTab, setActiveSubTab] = useState< + "overview" | "crashes" | "network" | "vitals" | "sessions" + >("overview"); + const [selectedEvent, setSelectedEvent] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedVitalFilter, setSelectedVitalFilter] = useState(null); + const [simulating, setSimulating] = useState(false); + const [copiedKey, setCopiedKey] = useState(null); + + const copyToClipboard = (text: string, key: string) => { + navigator.clipboard.writeText(text); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 1500); + }; + + // --- Extract All Frontend Records --- + const frontendEvents = useMemo(() => { + const events: FrontendEvent[] = []; + + logs.forEach((log) => { + const resAttrs = log.resource?.attributes ?? []; + const service = getServiceName(resAttrs); + + (log.scopeLogs ?? []).forEach((sl) => { + const scopeName = sl.scope?.name || ""; + + (sl.logRecords ?? []).forEach((r, recordIdx) => { + const attrs = r.attributes ?? []; + const source = getAttributeValue(attrs, "source"); + const vitalName = getAttributeValue(attrs, "rum.vital"); + const browserUrl = getAttributeValue(attrs, "browser.url"); + const sessionId = getAttributeValue(attrs, "session.id"); + const isRumScope = scopeName === "otel-rum"; + + // If this record has frontend RUM traits + if ( + source === "frontend" || + isRumScope || + Boolean(vitalName) || + Boolean(browserUrl) || + Boolean(sessionId) + ) { + let breadcrumbs: BreadcrumbItem[] = []; + const rawBreadcrumbs = getAttributeValue(attrs, "breadcrumbs"); + if (rawBreadcrumbs) { + try { + breadcrumbs = JSON.parse(rawBreadcrumbs); + } catch { + breadcrumbs = []; + } + } + + const rawMap: Record = {}; + attrs.forEach((a) => { + rawMap[a.key] = + a.value?.stringValue ?? + a.value?.intValue ?? + (a.value?.doubleValue !== undefined ? String(a.value.doubleValue) : "") ?? + ""; + }); + + let path = ""; + if (browserUrl) { + try { + path = new URL(browserUrl).pathname; + } catch { + path = browserUrl; + } + } + + const excType = getAttributeValue(attrs, "exception.type"); + const isHttp = excType === "HttpFailure"; + const sevNum = r.severityNumber ?? 0; + const isErr = sevNum >= 17 || excType !== ""; + + const vitalValStr = getAttributeValue(attrs, "rum.value"); + const vitalVal = vitalValStr ? parseFloat(vitalValStr) : undefined; + + const httpStatusStr = getAttributeValue(attrs, "http.response.status_code"); + const httpStatus = httpStatusStr ? parseInt(httpStatusStr, 10) : undefined; + + const id = `${log.id || "fe"}-${recordIdx}-${r.timeUnixNano || Date.now()}`; + + events.push({ + id, + timestamp: r.timeUnixNano ? Number(BigInt(r.timeUnixNano) / 1_000_000n) : Date.now(), + timeUnixNano: r.timeUnixNano || "", + body: r.body?.stringValue || "", + severityText: r.severityText || (isErr ? "ERROR" : "INFO"), + severityNumber: sevNum, + traceId: r.traceId || undefined, + service, + + isVital: Boolean(vitalName), + vitalName: vitalName || undefined, + vitalValue: vitalVal, + vitalElement: getAttributeValue(attrs, "rum.element") || undefined, + + url: browserUrl, + path: path || "/", + userAgent: getAttributeValue(attrs, "browser.userAgent"), + viewport: getAttributeValue(attrs, "browser.viewport"), + sessionId: sessionId || "unknown-session", + + isError: isErr && !isHttp, + isHttpFailure: isHttp, + exceptionType: excType || undefined, + exceptionMessage: + getAttributeValue(attrs, "exception.message") || r.body?.stringValue, + stacktrace: getAttributeValue(attrs, "exception.stacktrace") || undefined, + sourceFile: getAttributeValue(attrs, "source.file") || undefined, + sourceLine: getAttributeValue(attrs, "source.line") || undefined, + sourceColumn: getAttributeValue(attrs, "source.column") || undefined, + + httpMethod: getAttributeValue(attrs, "http.request.method") || undefined, + httpUrl: getAttributeValue(attrs, "url.full") || undefined, + httpStatus, + + breadcrumbs, + rawAttributes: rawMap, + }); + } + }); + }); + }); + + // Sort descending by timestamp + return events.sort((a, b) => b.timestamp - a.timestamp); + }, [logs]); + + // --- Aggregate Web Vitals --- + const vitalsMetrics = useMemo(() => { + const map: Record< + string, + { + values: number[]; + elements: Record; + latest: number; + p75: number; + average: number; + } + > = {}; + + frontendEvents.forEach((e) => { + if (e.isVital && e.vitalName && e.vitalValue !== undefined) { + const name = e.vitalName.toUpperCase(); + if (!map[name]) { + map[name] = { + values: [], + elements: {}, + latest: e.vitalValue, + p75: 0, + average: 0, + }; + } + map[name].values.push(e.vitalValue); + if (e.vitalElement) { + map[name].elements[e.vitalElement] = (map[name].elements[e.vitalElement] || 0) + 1; + } + } + }); + + Object.keys(map).forEach((name) => { + const vals = [...map[name].values].sort((a, b) => a - b); + const idx75 = Math.floor(vals.length * 0.75); + map[name].p75 = vals[Math.min(idx75, vals.length - 1)] || 0; + map[name].average = vals.reduce((sum, v) => sum + v, 0) / vals.length; + }); + + return map; + }, [frontendEvents]); + + // --- Aggregate Sessions --- + const sessions = useMemo(() => { + const sMap: Record< + string, + { + sessionId: string; + eventsCount: number; + errorsCount: number; + urls: Set; + userAgent: string; + lastSeen: number; + firstSeen: number; + } + > = {}; + + frontendEvents.forEach((e) => { + const sId = e.sessionId || "anonymous"; + if (!sMap[sId]) { + sMap[sId] = { + sessionId: sId, + eventsCount: 0, + errorsCount: 0, + urls: new Set(), + userAgent: e.userAgent, + lastSeen: e.timestamp, + firstSeen: e.timestamp, + }; + } + sMap[sId].eventsCount++; + if (e.isError || e.isHttpFailure) sMap[sId].errorsCount++; + if (e.path) sMap[sId].urls.add(e.path); + if (e.timestamp > sMap[sId].lastSeen) sMap[sId].lastSeen = e.timestamp; + if (e.timestamp < sMap[sId].firstSeen) sMap[sId].firstSeen = e.timestamp; + }); + + return Object.values(sMap).sort((a, b) => b.lastSeen - a.lastSeen); + }, [frontendEvents]); + + // --- Filtered Events for Active Subtab --- + const filteredEvents = useMemo(() => { + let result = frontendEvents; + + if (activeSubTab === "crashes") { + result = result.filter((e) => e.isError); + } else if (activeSubTab === "network") { + result = result.filter((e) => e.isHttpFailure); + } else if (activeSubTab === "vitals") { + result = result.filter((e) => e.isVital); + if (selectedVitalFilter) { + result = result.filter((e) => e.vitalName?.toUpperCase() === selectedVitalFilter); + } + } + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (e) => + e.body.toLowerCase().includes(q) || + e.path.toLowerCase().includes(q) || + (e.exceptionType && e.exceptionType.toLowerCase().includes(q)) || + (e.httpUrl && e.httpUrl.toLowerCase().includes(q)) || + (e.sessionId && e.sessionId.toLowerCase().includes(q)) || + (e.traceId && e.traceId.toLowerCase().includes(q)) + ); + } + + return result; + }, [frontendEvents, activeSubTab, selectedVitalFilter, searchQuery]); + + // --- Simulation Generator --- + const handleSimulate = async () => { + setSimulating(true); + try { + const traceId = Array.from({ length: 32 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(""); + const sessionId = Array.from({ length: 16 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(""); + const nowMs = Date.now(); + + const sampleBatch = { + resourceLogs: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: "storefront-web" } }, + { key: "deployment.environment", value: { stringValue: "production" } }, + ], + }, + scopeLogs: [ + { + scope: { name: "otel-rum" }, + logRecords: [ + // Vital 1: FCP + { + timeUnixNano: String((nowMs - 5000) * 1e6), + severityText: "INFO", + severityNumber: 9, + body: { stringValue: "FCP 850" }, + attributes: [ + { key: "source", value: { stringValue: "frontend" } }, + { key: "rum.vital", value: { stringValue: "FCP" } }, + { key: "rum.value", value: { stringValue: "850" } }, + { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, + { key: "session.id", value: { stringValue: sessionId } }, + { key: "browser.viewport", value: { stringValue: "1920x1080" } }, + { + key: "browser.userAgent", + value: { + stringValue: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + }, + }, + ], + }, + // Vital 2: LCP + { + timeUnixNano: String((nowMs - 4000) * 1e6), + severityText: "INFO", + severityNumber: 9, + body: { stringValue: "LCP 1450" }, + attributes: [ + { key: "source", value: { stringValue: "frontend" } }, + { key: "rum.vital", value: { stringValue: "LCP" } }, + { key: "rum.value", value: { stringValue: "1450" } }, + { key: "rum.element", value: { stringValue: "IMG" } }, + { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, + { key: "session.id", value: { stringValue: sessionId } }, + { key: "browser.viewport", value: { stringValue: "1920x1080" } }, + ], + }, + // Vital 3: CLS + { + timeUnixNano: String((nowMs - 3000) * 1e6), + severityText: "INFO", + severityNumber: 9, + body: { stringValue: "CLS 0.045" }, + attributes: [ + { key: "source", value: { stringValue: "frontend" } }, + { key: "rum.vital", value: { stringValue: "CLS" } }, + { key: "rum.value", value: { stringValue: "0.045" } }, + { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, + { key: "session.id", value: { stringValue: sessionId } }, + ], + }, + // Network Failure: Failed fetch with traceparent + { + timeUnixNano: String((nowMs - 2000) * 1e6), + severityText: "ERROR", + severityNumber: 17, + traceId, + body: { stringValue: "POST /api/v1/payment failed with HTTP 500" }, + attributes: [ + { key: "source", value: { stringValue: "frontend" } }, + { key: "exception.type", value: { stringValue: "HttpFailure" } }, + { key: "http.request.method", value: { stringValue: "POST" } }, + { key: "url.full", value: { stringValue: "https://store.acme.com/api/v1/payment" } }, + { key: "http.response.status_code", value: { stringValue: "500" } }, + { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, + { key: "session.id", value: { stringValue: sessionId } }, + ], + }, + // Uncaught Error with user journey breadcrumbs + { + timeUnixNano: String((nowMs - 1000) * 1e6), + severityText: "ERROR", + severityNumber: 17, + traceId, + body: { stringValue: "TypeError: Cannot read properties of undefined (reading 'clientSecret')" }, + attributes: [ + { key: "source", value: { stringValue: "frontend" } }, + { key: "exception.type", value: { stringValue: "TypeError" } }, + { + key: "exception.message", + value: { stringValue: "Cannot read properties of undefined (reading 'clientSecret')" }, + }, + { + key: "exception.stacktrace", + value: { + stringValue: + "TypeError: Cannot read properties of undefined (reading 'clientSecret')\n at PaymentStep (https://store.acme.com/static/js/checkout.min.js:42:115)\n at Object.renderWithHooks (react-dom.production.min.js:14:1287)\n at updateFunctionComponent (react-dom.production.min.js:17:1548)", + }, + }, + { key: "source.file", value: { stringValue: "https://store.acme.com/static/js/checkout.min.js" } }, + { key: "source.line", value: { stringValue: "42" } }, + { key: "source.column", value: { stringValue: "115" } }, + { key: "browser.url", value: { stringValue: "https://store.acme.com/checkout" } }, + { key: "session.id", value: { stringValue: sessionId } }, + { key: "browser.viewport", value: { stringValue: "1920x1080" } }, + { + key: "browser.userAgent", + value: { + stringValue: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + }, + }, + { + key: "breadcrumbs", + value: { + stringValue: JSON.stringify([ + { t: nowMs - 15000, category: "navigation", message: "pushState /cart" }, + { t: nowMs - 12000, category: "fetch", message: "GET /api/v1/cart -> 200" }, + { t: nowMs - 9000, category: "navigation", message: "pushState /checkout" }, + { t: nowMs - 5000, category: "fetch", message: "GET /api/v1/shipping-rates -> 200" }, + { t: nowMs - 2000, category: "fetch", message: "POST /api/v1/payment -> 500" }, + { t: nowMs - 1000, category: "console.error", message: "Payment authorization crashed" }, + ]), + }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + + await fetch("/v1/logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sampleBatch), + }); + } catch (err) { + console.error("Simulation error:", err); + } finally { + setTimeout(() => setSimulating(false), 500); + } + }; + + const totalErrors = frontendEvents.filter((e) => e.isError).length; + const totalHttpFailures = frontendEvents.filter((e) => e.isHttpFailure).length; + const totalVitals = frontendEvents.filter((e) => e.isVital).length; + + return ( +
+ {/* Top Banner & KPI summary */} +
+
+
+
+ +
+
+
+

+ Frontend & Real User Monitoring (RUM) +

+ + otel-rum.js + +
+

+ Real user performance, Web Vitals (LCP/INP/CLS), browser exceptions, and breadcrumbs + correlated with backend traces. +

+
+
+ +
+ + +
+
+ + {/* Real KPI Metrics Cards */} +
+
+ + + Total Signals + +
+ {frontendEvents.length} +
+ Live frontend telemetry +
+ +
+ + + Web Vitals + +
+ {totalVitals} +
+ LCP / INP / CLS / FCP / TTFB +
+ +
+ + + Browser Crashes + +
0 ? "text-rose-400" : "text-white" + }`} + > + {totalErrors} +
+ Uncaught exceptions +
+ +
+ + + Network Failures + +
0 ? "text-amber-400" : "text-white" + }`} + > + {totalHttpFailures} +
+ Failed fetch / XHR +
+ +
+ + + Sessions + +
+ {sessions.length} +
+ Unique client tabs +
+
+
+ + {/* Core Web Vitals Dashboard */} +
+
+
+ +

+ Core Web Vitals Assessment (Google Performance Standards) +

+
+ + p75 evaluation metric + +
+ +
+ {(["LCP", "INP", "CLS", "FCP", "TTFB"] as const).map((key) => { + const threshold = VITAL_THRESHOLDS[key]; + const data = vitalsMetrics[key]; + const hasData = Boolean(data && data.values.length > 0); + const value = hasData ? data.p75 : 0; + const rating = hasData ? getVitalRating(key, value) : "good"; + + const ratingColors = { + good: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10", + "needs-improvement": "text-amber-400 border-amber-500/30 bg-amber-500/10", + poor: "text-rose-400 border-rose-500/30 bg-rose-500/10", + }[rating]; + + const ratingBadge = { + good: "bg-emerald-500/20 text-emerald-300", + "needs-improvement": "bg-amber-500/20 text-amber-300", + poor: "bg-rose-500/20 text-rose-300", + }[rating]; + + const ratingLabel = { + good: "Good", + "needs-improvement": "Needs Improvement", + poor: "Poor", + }[rating]; + + return ( +
{ + setActiveSubTab("vitals"); + setSelectedVitalFilter(key); + }} + className={`cursor-pointer rounded-xl border p-4 transition-all hover:scale-[1.02] ${ + hasData + ? `border-white/10 bg-slate-950/40 hover:border-white/20 ${ratingColors}` + : "border-white/5 bg-slate-950/20 opacity-70" + }`} + > +
+ {key} + {hasData ? ( + + {ratingLabel} + + ) : ( + No samples + )} +
+ +
+ + {hasData ? threshold.format(value) : "—"} + + {hasData && ( + + ({data.values.length} {data.values.length === 1 ? "sample" : "samples"}) + + )} +
+ +

+ {threshold.description} +

+ +
+ Target: ≤ {threshold.format(threshold.good)} + {hasData && data.elements && Object.keys(data.elements).length > 0 && ( + + Top tag: {Object.keys(data.elements)[0]} + + )} +
+
+ ); + })} +
+
+ + {/* Subnavigation Bar & Search */} +
+
+ {[ + { id: "overview", label: "All Events", count: frontendEvents.length, icon: Layers }, + { id: "crashes", label: "Crashes & Errors", count: totalErrors, icon: AlertCircle }, + { id: "network", label: "HTTP Failures", count: totalHttpFailures, icon: WifiOff }, + { id: "vitals", label: "Web Vitals Log", count: totalVitals, icon: Activity }, + { id: "sessions", label: "User Sessions", count: sessions.length, icon: Monitor }, + ].map((tab) => { + const Icon = tab.icon; + const isActive = activeSubTab === tab.id; + return ( + + ); + })} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Filter URL, error, session..." + className="w-full rounded-xl border border-white/10 bg-slate-950/60 pl-8 pr-3 py-1.5 text-xs text-white placeholder-slate-500 outline-none focus:border-indigo-500 font-mono transition-colors" + /> +
+
+ + {/* Main Content Area */} + {frontendEvents.length === 0 ? ( + /* Empty State with Onboarding */ +
+
+ +
+

No Frontend Signals Detected Yet

+

+ Add the otel-rum.js script to your web + application to automatically stream Web Vitals, uncaught JS errors, and user journeys. +

+ +
+ + +
+
+ ) : activeSubTab === "sessions" ? ( + /* User Sessions View */ +
+
+ + Active Client Browser Sessions ({sessions.length}) + + + Correlated user browser lifetimes + +
+ +
+ {sessions.map((sess) => { + const ua = parseUserAgent(sess.userAgent); + return ( +
+
+
+ {ua.isMobile ? ( + + ) : ( + + )} +
+
+
+ {sess.sessionId} + + {ua.browser} on {ua.os} + + {sess.errorsCount > 0 ? ( + + + {sess.errorsCount} {sess.errorsCount === 1 ? "error" : "errors"} + + ) : ( + + Clean + + )} +
+
+ Visited: + {Array.from(sess.urls).slice(0, 3).map((u) => ( + + {u} + + ))} + {sess.urls.size > 3 && ( + +{sess.urls.size - 3} more + )} +
+
+
+ +
+
+
{sess.eventsCount} events
+
+ {new Date(sess.lastSeen).toLocaleTimeString()} +
+
+ +
+
+ ); + })} +
+
+ ) : ( + /* Event Stream View */ +
+
+ + Frontend Telemetry Feed ({filteredEvents.length}) + + + Showing {filteredEvents.length} of {frontendEvents.length} events + +
+ +
+ {filteredEvents.length === 0 ? ( +
+ No events match the selected filters or search query. +
+ ) : ( + filteredEvents.map((evt) => { + const isSelected = selectedEvent?.id === evt.id; + const ua = parseUserAgent(evt.userAgent); + + return ( +
setSelectedEvent(evt)} + className={`p-4 flex flex-col gap-2.5 cursor-pointer transition-all ${ + isSelected + ? "bg-indigo-500/10 border-l-4 border-l-indigo-500" + : "hover:bg-white/[0.02]" + }`} + > +
+
+ {/* Event Category Badge */} + {evt.isVital ? ( + + + {evt.vitalName} + + ) : evt.isHttpFailure ? ( + + + HTTP {evt.httpStatus || "ERR"} + + ) : evt.isError ? ( + + + {evt.exceptionType || "Error"} + + ) : ( + + RUM Info + + )} + + + {evt.isVital + ? `${evt.vitalName}: ${ + VITAL_THRESHOLDS[evt.vitalName || ""]?.format( + evt.vitalValue || 0 + ) || evt.vitalValue + } ${evt.vitalElement ? `(element: <${evt.vitalElement}>)` : ""}` + : evt.body} + + + {evt.path && ( + + {evt.path} + + )} +
+ +
+ {new Date(evt.timestamp).toLocaleTimeString()} + +
+
+ + {/* Metadata chips */} +
+ Service: {evt.service} + {ua.browser !== "Browser" && ( + Client: {ua.browser} ({ua.os}) + )} + {evt.viewport && Viewport: {evt.viewport}} + {evt.traceId && ( + + Trace: {evt.traceId.slice(0, 8)}... + + )} + {evt.breadcrumbs.length > 0 && ( + + {evt.breadcrumbs.length} breadcrumbs recorded + + )} +
+
+ ); + }) + )} +
+
+ )} + + {/* Slide-out Event Detail Inspector Drawer */} + {selectedEvent && ( +
+
+
+
+ + {selectedEvent.exceptionType || selectedEvent.vitalName || "Frontend Event"} + +

+ {selectedEvent.body} +

+
+

+ Occurred at {new Date(selectedEvent.timestamp).toLocaleString()} on service{" "} + {selectedEvent.service} +

+
+ + +
+ +
+ {/* Left Column: Context & Client info */} +
+
+ + Client & Navigation Environment + + {selectedEvent.url && ( +
+ Target Page URL: + + {selectedEvent.url} + + +
+ )} + {selectedEvent.sessionId && ( +
+ Browser Session ID: + {selectedEvent.sessionId} +
+ )} + {selectedEvent.viewport && ( +
+ Viewport Size: + {selectedEvent.viewport} +
+ )} + {selectedEvent.userAgent && ( +
+ User Agent: + + {selectedEvent.userAgent} + +
+ )} +
+ + {/* Correlated Backend Trace */} + {selectedEvent.traceId && ( +
+
+ + Correlated Backend Trace + + + {selectedEvent.traceId} + +
+ +
+ )} +
+ + {/* Right Column: Breadcrumbs Timeline */} +
+
+
+ + User Journey Breadcrumbs ({selectedEvent.breadcrumbs.length}) + + Actions prior to event +
+ + {selectedEvent.breadcrumbs.length === 0 ? ( +

+ No breadcrumbs attached to this event. +

+ ) : ( +
+ {selectedEvent.breadcrumbs.map((b, idx) => ( +
+ + {b.category} + + {b.message} +
+ ))} +
+ )} +
+
+
+ + {/* Stacktrace if available */} + {selectedEvent.stacktrace && ( +
+
+ + + JavaScript Stacktrace + + +
+
+                {selectedEvent.stacktrace}
+              
+
+ )} +
+ )} +
+ ); +}; + +export default FrontendVisualizer; diff --git a/otel-ui/src/components/GaugeMeter.tsx b/otel-ui/src/components/GaugeMeter.tsx new file mode 100644 index 0000000..3ee2ea7 --- /dev/null +++ b/otel-ui/src/components/GaugeMeter.tsx @@ -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 ( +
+
+
+
+ {service && ( + + + {service} + + )} + {podCount !== undefined && podCount > 1 && ( + + ∑ + {podCount} pods + + )} +
+ + {name} + +
+ + {statusColor.label} + +
+ +
+ + + + + + + + + + + + {/* Background Track */} + + + {/* 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 ( + + ); + })} + + {/* Active Value Arc */} + {percent > 0 && ( + + )} + + {/* Needle / Value dot indicator */} + {percent > 0 && (() => { + const tip = polarToCartesian(centerX, centerY, radius, valueAngle); + return ( + + ); + })()} + + + {/* Center Readout */} +
+ {soloPod ? ( + + {soloPod} + + ) : aggLabel ? ( + + {aggLabel} + + ) : podCount !== undefined && podCount > 1 ? ( + + {isSum ? "Sum (∑)" : "Avg (μ)"} + + ) : null} +
+ + {formatNumber(value)} + + {unit && ( + + {unit} + + )} +
+ {podCount !== undefined && podCount > 1 ? ( + + {isSum ? `~${formatNumber(value / podCount)}/pod` : `${podCount} pods avg`} + + ) : subtitle ? ( + + {subtitle} + + ) : null} +
+
+ + {/* Footer range indicators & trend */} +
+ + min {formatNumber(min)} + + {trend && ( + + {trend.direction === "up" && } + {trend.direction === "down" && } + {trend.direction === "flat" && } + {trend.text} + + )} + {avg !== undefined && ( + + avg {formatNumber(avg)} + + )} + + max {formatNumber(max)} + +
+ + {/* Pod Breakdown pills if available */} + {podSummaries && podSummaries.length > 1 && ( +
+ {podSummaries.slice(0, 3).map((ps) => ( +
+ {ps.pod} + {formatNumber(ps.value)} +
+ ))} + {podSummaries.length > 3 && ( + + +{podSummaries.length - 3} + + )} +
+ )} +
+ ); +}; diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx new file mode 100644 index 0000000..5774c5e --- /dev/null +++ b/otel-ui/src/components/IssuesVisualizer.tsx @@ -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 = { + 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 ( + + + + + + + + + + + + + ) +} + + +const StatusBadge = ({ issue }: { issue: Issue }) => { + const status = issueStatusOf(issue) + const meta = issueStatusMeta[status] + return ( + + {meta.label} + + ) +} + +const StacktraceView = ({ stacktrace }: { stacktrace: string }) => ( +
+    {stacktrace}
+  
+) + +const BreadcrumbsView = ({ issue }: { issue: Issue }) => { + const crumbs = latestBreadcrumbs(issue) + if (!crumbs.length) return null + return ( +
+

+ + Breadcrumbs before error +

+
+ {crumbs.map((crumb, idx) => ( +
+ + {new Date(crumb.t).toLocaleTimeString([], { hour12: false })} + + + {crumb.category} + + {crumb.message.slice(0, 200)} +
+ ))} +
+
+ ) +} + +const SampleContextChips = ({ sample }: { sample: IssueSample }) => { + const ctx = sampleContext(sample) + if (!ctx.length) return null + return ( + + {ctx.join(" · ")} + + ) +} + +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 ( +
+ {actions.map(({ to, label, Icon }) => ( + + ))} +
+ ) +} + +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 ( +
+
+ +
setExpanded((prev) => !prev)} + className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4" + > + + + {meta.label} + + + + {issue.service} + + + + {issue.title} + + + + + + ×{issue.count} + + + + + + last seen {formatRelativeTime(issue.lastSeen / 1e6)} + + + {expanded ? : } +
+ + {expanded && ( +
+
+
+ First seen {formatRelativeTime(issue.firstSeen / 1e6)} + {issue.affectedServices.length > 1 && ( + Affects: {issue.affectedServices.join(", ")} + )} + {issue.status === "resolved" && issue.resolvedAt ? ( + Resolved {formatRelativeTime(issue.resolvedAt / 1e6)} + ) : null} +
+ +
+ + {stacktrace && ( +
+

+ {hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"} +

+ +
+ )} + + + +
+ {(contextSample?.userAgent || latestSample?.userAgent) && ( + + + {(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)} + + )} + {(contextSample?.release || latestSample?.release) && ( + release: {contextSample?.release || latestSample?.release} + )} + {(contextSample?.environment || latestSample?.environment) && ( + env: {contextSample?.environment || latestSample?.environment} + )} +
+ +
+

+ Recent occurrences ({issue.samples.length}) +

+
+ {[...issue.samples].reverse().map((sample, idx) => ( +
+ + {new Date(sample.timestamp / 1e6).toLocaleTimeString([], { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + + {sample.message} + {sample.url && ( + 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} + > + + {sample.url.replace(/^https?:\/\//, "")} + + )} + {sample.traceId && ( + + )} +
+ ))} +
+
+ + {latestSample?.traceId && ( +
+ +
+ )} +
+ )} +
+ ) +} + +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("all") + const [kindFilter, setKindFilter] = useState("all") + const [excludeSpamIPs, setExcludeSpamIPs] = useState(false) + const [showSpamIPs, setShowSpamIPs] = useState(false) + const [spamIPs, setSpamIPs] = useState([]) + const [spamIPsLoading, setSpamIPsLoading] = useState(false) + const [spamIPsError, setSpamIPsError] = useState(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 ( +
+ + Loading issues... +
+ ) + } + + return ( +
+
+
+ + + Deduplicated Issues + + + {/* Status filter chips */} +
+ {statusFilters.map(({ value, label }) => ( + + ))} +
+ + + + {/* Kind filter chips */} +
+ {kindFilters.map(({ value, label, Icon }) => ( + + ))} +
+ + + {filteredIssues.length} shown + +
+ +
+ {error && Failed to load: {error}} + + +
+
+ + {showSpamIPs && ( +
+
+
+
Promoted honeypot IPs
+
Used by the ingress tarpit synchronizer.
+
+ +
+ {spamIPsLoading &&
Loading…
} + {spamIPsError &&
Failed to load: {spamIPsError}
} + {!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && ( +
No promoted IPs.
+ )} + {spamIPs.length > 0 && ( +
+ {spamIPs.map((entry) => ( +
+ {entry.ip} + ×{entry.count} +
+ ))} +
+ )} +
+ )} + + {filteredIssues.length === 0 ? ( +
+
+ +
+

+ {issues.length === 0 ? "No issues detected" : "No issues match your filters"} +

+

+ {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."} +

+
+ ) : ( +
+ {filteredIssues.map((issue) => ( + + ))} +
+ )} +
+ ) +} + +export default IssuesVisualizer diff --git a/otel-ui/src/components/MetricsVisualizer.tsx b/otel-ui/src/components/MetricsVisualizer.tsx new file mode 100644 index 0000000..c0a4a9d --- /dev/null +++ b/otel-ui/src/components/MetricsVisualizer.tsx @@ -0,0 +1,1107 @@ +import { useMemo, useState } from "react"; +import { + AreaChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { Activity, BarChart3, Sparkles, Filter, Gauge, Search, X, Boxes, Eye } from "lucide-react"; +import { useMetricsDetails } from "../hooks/useMetrics"; +import type { MetricDataPoint, OtelAttribute } from "../lib/types"; +import { formatAttributeValue, getServicePalette, getPodPalette } from "../lib/otel-utils"; +import { GaugeMeter } from "./GaugeMeter"; + +const valueFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 3, +}); + +const compactFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + notation: "compact", +}); + +const resolveValue = (point: MetricDataPoint) => { + if (typeof point.asDouble === "number") return point.asDouble; + if (point.asInt !== undefined) return Number(BigInt(point.asInt)); + return 0; +}; + +interface MetricCardProps { + name: string; + service: string; + type: "gauge" | "sum" | "histogram"; + points: MetricDataPoint[]; + unit?: string; + pods?: string[]; + index: number; +} + +const MetricCard = ({ name, service, type, points, unit, pods = [], index }: MetricCardProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const [viewMode, setViewMode] = useState<"chart" | "gauge">("chart"); + const [selectedPod, setSelectedPod] = useState("all"); + + const isGaugeLike = useMemo(() => { + if (type === "gauge") return true; + const lower = name.toLowerCase(); + return ( + lower.includes("cpu") || + lower.includes("memory") || + lower.includes("usage") || + lower.includes("ratio") || + lower.includes("utilization") || + lower.includes("duration") || + lower.includes("latency") || + lower.includes("rate") || + lower.includes("load") || + lower.includes("temp") || + unit === "%" + ); + }, [type, name, unit]); + + const [aggMode, setAggMode] = useState<"avg" | "lines" | "sum" | "max" | "stacked">( + isGaugeLike ? "avg" : "sum" + ); + + const servicePalette = useMemo(() => getServicePalette(service), [service]); + + // Compute latest per-pod values + const podLatestMap = useMemo(() => { + const map = new Map(); + points.forEach((p) => { + const pod = p.pod || "default"; + map.set(pod, resolveValue(p)); + }); + return map; + }, [points]); + + const activePods = useMemo(() => { + if (pods && pods.length > 0) return pods; + const fromMap = Array.from(podLatestMap.keys()).filter((p) => p !== "default"); + return fromMap.length > 0 ? fromMap : []; + }, [pods, podLatestMap]); + + const isMultiPod = activePods.length > 1; + + const podSummaries = useMemo(() => { + return Array.from(podLatestMap.entries()).map(([pod, value]) => ({ + pod, + value, + })); + }, [podLatestMap]); + + // Build chart dataset with time alignment and zero-order hold + const chartData = useMemo(() => { + // 1. Solo Mode: filter directly to the chosen pod + if (selectedPod !== "all") { + const podPoints = points + .filter((p) => (p.pod || "default") === selectedPod) + .sort((a, b) => { + const aT = BigInt(a.timeUnixNano || "0"); + const bT = BigInt(b.timeUnixNano || "0"); + return aT < bT ? -1 : aT > bT ? 1 : 0; + }); + + return podPoints.map((p) => { + const val = resolveValue(p); + return { + time: Number(BigInt(p.timeUnixNano ?? "0") / 1000000n), + value: val, + total: val, + avg: val, + min: val, + max: val, + [selectedPod]: val, + attributes: p.attributes ?? [], + }; + }); + } + + // 2. Single pod (or no pod attributes present) + if (!isMultiPod) { + const sorted = [...points].sort((a, b) => { + const aT = BigInt(a.timeUnixNano || "0"); + const bT = BigInt(b.timeUnixNano || "0"); + return aT < bT ? -1 : aT > bT ? 1 : 0; + }); + return sorted.map((p) => { + const val = resolveValue(p); + return { + time: Number(BigInt(p.timeUnixNano ?? "0") / 1000000n), + value: val, + total: val, + avg: val, + min: val, + max: val, + attributes: p.attributes ?? [], + }; + }); + } + + // 3. Multi-pod: bucket time series with sample-and-hold so pod scrape jitter doesn't cause sawteeth + const times = points + .map((p) => Number(BigInt(p.timeUnixNano ?? "0") / 1000000n)) + .filter((t) => t > 0); + if (times.length === 0) return []; + + const minT = Math.min(...times); + const maxT = Math.max(...times); + const span = Math.max(maxT - minT, 1000); + const numBuckets = Math.min(50, Math.max(12, Math.floor(points.length / Math.max(1, activePods.length)))); + const bucketDuration = span / numBuckets; + + const bucketPoints: Array> = Array.from({ length: numBuckets }, () => ({})); + + points.forEach((p) => { + const t = Number(BigInt(p.timeUnixNano ?? "0") / 1000000n); + if (t < minT || t > maxT) return; + const bIdx = Math.min(numBuckets - 1, Math.max(0, Math.floor((t - minT) / bucketDuration))); + const pod = p.pod || activePods[0] || "default"; + const val = resolveValue(p); + if (!bucketPoints[bIdx][pod]) { + bucketPoints[bIdx][pod] = []; + } + bucketPoints[bIdx][pod].push(val); + }); + + const lastKnownPodValue: Record = {}; + const buckets: Array<{ + time: number; + avg: number; + sum: number; + min: number; + max: number; + value: number; + total: number; + podCount: number; + attributes: OtelAttribute[]; + [pod: string]: any; + }> = []; + + for (let i = 0; i < numBuckets; i++) { + const bTime = Math.round(minT + (i + 0.5) * bucketDuration); + const podEntries = bucketPoints[i]; + + // Update last known pod values with latest observed in this window + activePods.forEach((pod) => { + if (podEntries[pod] && podEntries[pod].length > 0) { + const vals = podEntries[pod]; + lastKnownPodValue[pod] = vals[vals.length - 1]; + } + }); + + const currentValues = activePods + .map((pod) => lastKnownPodValue[pod]) + .filter((v): v is number => v !== undefined); + + if (currentValues.length === 0) continue; + + const sum = currentValues.reduce((a, b) => a + b, 0); + const avg = sum / currentValues.length; + const min = Math.min(...currentValues); + const max = Math.max(...currentValues); + + let activeVal = avg; + if (aggMode === "sum" || aggMode === "stacked") { + activeVal = sum; + } else if (aggMode === "max") { + activeVal = max; + } + + const row: any = { + time: bTime, + avg, + sum, + min, + max, + value: activeVal, + total: sum, + podCount: currentValues.length, + attributes: [], + }; + + activePods.forEach((pod) => { + row[pod] = lastKnownPodValue[pod] ?? 0; + }); + + buckets.push(row); + } + + return buckets; + }, [points, selectedPod, isMultiPod, activePods, aggMode]); + + const stats = useMemo(() => { + if (chartData.length === 0) return null; + const values = chartData.map((d) => d.value); + const min = Math.min(...values); + const max = Math.max(...values); + const avg = values.reduce((a: number, b: number) => a + b, 0) / values.length; + return { min, max, avg, count: chartData.length }; + }, [chartData]); + + const latestPoint = chartData[chartData.length - 1]; + const historyValues = useMemo(() => chartData.map((d) => d.value), [chartData]); + + // Current KPI readout + const currentDisplay = useMemo(() => { + if (selectedPod !== "all") { + const val = podLatestMap.get(selectedPod) ?? (latestPoint ? latestPoint.value : 0); + return { + value: val, + label: "Solo Pod", + subtext: selectedPod, + }; + } + if (!isMultiPod) { + const val = latestPoint ? latestPoint.value : 0; + return { + value: val, + label: "Current", + subtext: undefined, + }; + } + const vals = Array.from(podLatestMap.values()); + const sum = vals.reduce((a, b) => a + b, 0); + const avg = vals.length > 0 ? sum / vals.length : 0; + const max = vals.length > 0 ? Math.max(...vals) : 0; + + if (aggMode === "sum" || aggMode === "stacked") { + return { + value: sum, + label: "Fleet Sum (∑)", + subtext: `~${valueFormatter.format(avg)}/pod`, + }; + } + if (aggMode === "max") { + return { + value: max, + label: "Fleet Max", + subtext: `Peak of ${activePods.length} pods`, + }; + } + return { + value: avg, + label: "Fleet Avg (μ)", + subtext: `Across ${activePods.length} pods (∑: ${compactFormatter.format(sum)})`, + }; + }, [selectedPod, isMultiPod, podLatestMap, latestPoint, aggMode, activePods]); + + const gradients = [ + { from: "#818CF8", to: "#38BDF8" }, + { from: "#34D399", to: "#38BDF8" }, + { from: "#F472B6", to: "#FB7185" }, + { from: "#FBBF24", to: "#F59E0B" }, + ]; + const color = gradients[index % gradients.length]; + const gradientId = `gradient-${index}-${name.replace(/[^a-zA-Z0-9]/g, "_")}`; + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+
+ + {new Date(data.time).toLocaleTimeString([], { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + })} + +
+
+ {selectedPod !== "all" + ? "Solo Value" + : isMultiPod + ? aggMode === "avg" + ? "Fleet Avg (μ)" + : aggMode === "sum" || aggMode === "stacked" + ? "Fleet Sum (∑)" + : aggMode === "max" + ? "Fleet Max" + : "Value" + : "Value"} +
+ + {valueFormatter.format(data.value ?? data.avg ?? data.total ?? 0)} {unit || ""} + +
+
+ + {/* If fleet average, show min-to-max envelope in tooltip */} + {isMultiPod && selectedPod === "all" && data.min !== undefined && data.max !== undefined && ( +
+ Spread: + + min {valueFormatter.format(data.min)} • max{" "} + {valueFormatter.format(data.max)} + +
+ )} + + {/* Pod breakdown in tooltip */} + {isMultiPod && selectedPod === "all" && ( +
+
+ Pod Breakdown ({activePods.length}): + click pod to solo +
+ {activePods.map((pod) => { + const pal = getPodPalette(pod); + const val = data[pod] ?? 0; + return ( +
+ + + {pod} + + + {valueFormatter.format(val)} {unit || ""} + +
+ ); + })} +
+ )} + +
+ Service: + {service} +
+ + {data.attributes && data.attributes.length > 0 && ( +
+ {data.attributes.map((attr: OtelAttribute) => ( +
+ {attr.key}: + + {formatAttributeValue(attr.value)} + +
+ ))} +
+ )} +
+ ); + } + return null; + }; + + return ( +
+
+
+
+ {type === "gauge" ? ( + + ) : index % 2 === 0 ? ( + + ) : ( + + )} +
+
+
+ + + {service} + + + {/* Solo Indicator if a pod is isolated */} + {selectedPod !== "all" ? ( + + ) : isMultiPod ? ( + + + {activePods.length} pods + + ) : null} + + + {type} + +
+ +

+ {name} +

+

+ {stats?.count} samples {unit ? `• ${unit}` : ""} +

+
+
+ +
+ {/* Multi-pod aggregation selector if in chart mode and not soloed */} + {viewMode === "chart" && isMultiPod && selectedPod === "all" && ( +
+ + + + + +
+ )} + + {/* View switcher: Gauge vs Timeline Chart */} +
+ + +
+ +
+
+ {currentDisplay.label} +
+
+ {valueFormatter.format(currentDisplay.value)} + {unit && {unit}} +
+ {currentDisplay.subtext && ( +
+ {currentDisplay.subtext} +
+ )} +
+
+
+ + {/* Body: either Gauge or Area Chart */} + {viewMode === "gauge" ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + {activePods.map((pod) => { + const pal = getPodPalette(pod); + const safePodId = pod.replace(/[^a-zA-Z0-9]/g, "_"); + return ( + + + + + ); + })} + + + + new Date(t).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + } + stroke="#475569" + fontSize={10} + tickLine={false} + axisLine={false} + minTickGap={30} + /> + compactFormatter.format(v)} + width={36} + /> + } /> + + {/* Render series based on Solo mode or Aggregation mode */} + {selectedPod !== "all" ? ( + + ) : !isMultiPod ? ( + + ) : aggMode === "avg" ? ( + <> + + + + + ) : aggMode === "lines" ? ( + activePods.map((pod) => { + const pal = getPodPalette(pod); + return ( + + ); + }) + ) : aggMode === "stacked" ? ( + <> + {activePods.map((pod) => { + const pal = getPodPalette(pod); + const safePodId = pod.replace(/[^a-zA-Z0-9]/g, "_"); + return ( + + ); + })} + + + ) : aggMode === "max" ? ( + + ) : ( + + )} + + +
+ )} + + {/* Pod breakdown pill list with click-to-solo */} + {isMultiPod && ( +
+
+ + Pods ({activePods.length}): +
+ + {selectedPod !== "all" && ( + + )} + + {podSummaries.map((ps) => { + const pal = getPodPalette(ps.pod); + const isSelected = selectedPod === ps.pod; + return ( + + ); + })} +
+ )} + +
+
+
+
+ Min +
+
+ {stats ? compactFormatter.format(stats.min) : "—"} +
+
+
+
+ Avg +
+
+ {stats ? compactFormatter.format(stats.avg) : "—"} +
+
+
+
+ Max +
+
+ {stats ? compactFormatter.format(stats.max) : "—"} +
+
+ +
+ + {isExpanded && latestPoint && ( +
+ {latestPoint.attributes && latestPoint.attributes.length > 0 ? ( + latestPoint.attributes.map((attr: OtelAttribute) => ( +
+ {attr.key} + + {formatAttributeValue(attr.value)} + +
+ )) + ) : ( +
+ No attributes present +
+ )} +
+ )} +
+
+ ); +}; + +export const MetricsVisualizer = () => { + const metricsDetails = useMetricsDetails(); + const [filterType, setFilterType] = useState<"all" | "gauge" | "sum" | "histogram">("all"); + const [selectedServiceFilter, setSelectedServiceFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const metricList = useMemo(() => { + return Object.values(metricsDetails ?? {}).filter((m) => m.points.length > 0); + }, [metricsDetails]); + + const availableServices = useMemo(() => { + const set = new Set(); + metricList.forEach((m) => { + if (m.service) set.add(m.service); + }); + return Array.from(set).sort(); + }, [metricList]); + + const filteredMetrics = useMemo(() => { + return metricList.filter((m) => { + if (filterType !== "all" && m.type !== filterType) return false; + if (selectedServiceFilter !== "all" && m.service !== selectedServiceFilter) return false; + if ( + search.trim() && + !m.name.toLowerCase().includes(search.toLowerCase().trim()) && + !m.service.toLowerCase().includes(search.toLowerCase().trim()) + ) { + return false; + } + return true; + }); + }, [metricList, filterType, selectedServiceFilter, search]); + + // Top gauges for the KPI bar + const topGauges = useMemo(() => { + return metricList.filter((m) => m.type === "gauge" || m.name.toLowerCase().includes("memory") || m.name.toLowerCase().includes("cpu") || m.name.toLowerCase().includes("usage") || m.name.toLowerCase().includes("ratio")).slice(0, 4); + }, [metricList]); + + if (metricList.length === 0) { + return ( +
+
+ +
+
+

No metric signals detected

+

+ The telemetry pipeline is established and waiting for metrics. Send gauge, counter, or histogram data to see interactive gauges and trend visualizations. +

+
+
+ ); + } + + return ( +
+ {/* Top Gauges Overview Dashboard */} + {topGauges.length > 0 && ( +
+
+
+ +

+ Live Gauge Dashboard +

+
+ + {topGauges.length} Active Gauges + +
+ +
+ {topGauges.map((g) => { + const podLatestMap = new Map(); + g.points.forEach((p) => { + const pod = p.pod || "default"; + podLatestMap.set(pod, resolveValue(p)); + }); + const podCount = (g.pods && g.pods.length > 0) ? g.pods.length : (podLatestMap.size > 1 ? podLatestMap.size : 1); + const isMulti = podCount > 1; + const podSum = Array.from(podLatestMap.values()).reduce((a, b) => a + b, 0); + const isGauge = g.type === "gauge" || g.name.toLowerCase().includes("cpu") || g.name.toLowerCase().includes("memory") || g.name.toLowerCase().includes("usage") || g.name.toLowerCase().includes("ratio") || g.unit === "%"; + const fleetAvg = isMulti ? podSum / podCount : podSum; + const displayVal = isMulti && isGauge ? fleetAvg : isMulti ? podSum : (g.points.length > 0 ? resolveValue(g.points[g.points.length - 1]) : 0); + const values = g.points.map((p) => resolveValue(p)); + const min = Math.min(...values); + const max = Math.max(...values); + const avg = values.reduce((a, b) => a + b, 0) / Math.max(1, values.length); + const summaries = Array.from(podLatestMap.entries()).map(([pod, value]) => ({ + pod, + value, + })); + + return ( + + ); + })} +
+
+ )} + + {/* Metric List Toolbar */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Filter metrics by name..." + className="h-9 w-52 sm:w-64 rounded-xl border border-white/10 bg-slate-900/50 pl-8 pr-8 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none" + /> + {search && ( + + )} +
+ + {availableServices.length > 1 && ( + + )} + + {/* Type filters */} +
+ + + + +
+
+ +
+ Showing {filteredMetrics.length} of {metricList.length} metrics +
+
+ + {/* Metrics Grid */} + {filteredMetrics.length === 0 ? ( +
+ No metrics match your search criteria. +
+ ) : ( +
+ {filteredMetrics.map((item, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/otel-ui/src/components/PatternsVisualizer.tsx b/otel-ui/src/components/PatternsVisualizer.tsx new file mode 100644 index 0000000..eb0dd1f --- /dev/null +++ b/otel-ui/src/components/PatternsVisualizer.tsx @@ -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 + review: (id: string, otherId: string, decision: "same" | "different") => Promise +}) => { + const [expanded, setExpanded] = useState(false) + const [related, setRelated] = useState>({}) + const [busy, setBusy] = useState(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 ( +
+
+ {members.map((issue) => )} +
+ {expanded && members[0] &&
+
Deterministic suggestions for {members[0].title}
+ {(related[members[0].id] ?? []).map((item) => { + const key = `${members[0].id}:${item.issue.id}` + return
+ {item.issue.title}{Math.round(item.score * 100)}%{item.reasons.join(" · ")} + + + {item.decision && reviewed: {item.decision}} +
+ })} +
} +
+ ) +} + +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
Loading deterministic patterns…
+ return
+
+
Similarity Patterns
Exact issues stay separate; broader deterministic patterns are reviewable.
+ 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" /> + +
+ {error &&
Failed to load patterns: {String(error)}
} + {visible.length === 0 ?
No deterministic patterns observed yet.
:
{visible.map((pattern) =>
+
{pattern.service}{pattern.kind}{pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrenceslast seen {formatRelativeTime(pattern.lastSeen / 1e6)}
+
{pattern.signature.split("\u0000").join(" · ")}
+ +
)}
} +
+} diff --git a/otel-ui/src/components/RolloutsVisualizer.tsx b/otel-ui/src/components/RolloutsVisualizer.tsx new file mode 100644 index 0000000..bda5344 --- /dev/null +++ b/otel-ui/src/components/RolloutsVisualizer.tsx @@ -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 = { + 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(null) + const [impactData, setImpactData] = useState>({}) + const [impactError, setImpactError] = useState(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
Loading cluster rollouts…
+ return
+
+
Cluster Rollouts
Read-only Kubernetes deployment state with optional Prometheus impact data.
+ +
+ {error &&
Rollout watcher unavailable: {String(error)}
} + {impactError &&
Impact query failed: {impactError}
} + {rollouts.length === 0 ?
No rollout data. Enable the Kubernetes watcher and grant its read-only RBAC permissions.
:
{rollouts.map((rollout) => { + const status = phaseStyle[rollout.phase] ?? phaseStyle.progressing + const Icon = status.Icon + const data = impactData[rollout.id] + return
+ + {selected === rollout.id &&
Started {formatRelativeTime(rollout.startedAt / 1e6)}{rollout.completedAt ? ` · completed ${formatRelativeTime(rollout.completedAt / 1e6)}` : ""}
{rollout.message || "No rollout message reported."}
{data &&
Prometheus error-rate points
{!data.configured ?
Prometheus is not configured.
: data.points.length === 0 ?
No matching Prometheus series.
:
{data.points.slice(-12).map((point) => {new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: {point.value})}
}
}
} +
+ })}
} +
+} diff --git a/otel-ui/src/components/SearchAndWatcher.tsx b/otel-ui/src/components/SearchAndWatcher.tsx new file mode 100644 index 0000000..5d90bac --- /dev/null +++ b/otel-ui/src/components/SearchAndWatcher.tsx @@ -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(""); + const [searchLimit, setSearchLimit] = useState(100); + const [searchTimeRange, setSearchTimeRange] = useState("24h"); + const [searchResults, setSearchResults] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(null); + + // Watcher state + const [watchQuery, setWatchQuery] = useState(""); + const [watchType, setWatchType] = useState(""); + const [watchTimeout, setWatchTimeout] = useState(30); + const [isWatching, setIsWatching] = useState(false); + const [caughtSignal, setCaughtSignal] = useState(null); + const [watchStatus, setWatchStatus] = useState(null); + const abortControllerRef = useRef(null); + + // UI helpers + const [copiedId, setCopiedId] = useState(null); + const [expandedIndex, setExpandedIndex] = useState(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 = { + "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 ( +
+ {/* Top Banner */} +
+
+
+
+
+ +
+

+ Archive Search & Live Signal Trap +

+
+

+ 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. +

+
+
+
+ + {/* SECTION 1: Live Signal Trap */} +
+
+
+ +

+ Live Signal Trap (/api/history/wait) +

+
+ + Long-poll subscriber for active debugging + +
+ +
+
+ +
+ 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" + /> +
+
+ +
+ + +
+ +
+
+ + +
+ + {isWatching ? ( + + ) : ( + + )} +
+
+ + {/* Watcher Status or Live Trigger Output */} + {watchStatus && ( +
+
+ + {watchStatus} +
+
+ )} + + {/* Captured Signal Preview */} + {caughtSignal && ( +
+
+
+ + {caughtSignal.type} + + + Captured at {new Date(caughtSignal.timestamp / 1_000_000).toLocaleTimeString()} + +
+ + {extractTraceId(caughtSignal) && ( + + )} +
+ +
+              {JSON.stringify(caughtSignal.data, null, 2)}
+            
+
+ )} +
+ + {/* SECTION 2: Persistent Storage Search */} +
+
+
+ +

+ Query Persistent Telemetry Store (/api/history/search) +

+
+ + Searches full database history beyond active memory + +
+ +
+
+
+ + 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" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ + {searchError && ( +
+ {searchError} +
+ )} + + {/* Results List */} + {searchResults !== null && ( +
+
+ Found {searchResults.length} persistent storage matches +
+ + {searchResults.length === 0 ? ( +
+ No telemetry entries matched your search criteria in the selected time range. +
+ ) : ( +
+ {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 ( +
+
setExpandedIndex(isExpanded ? null : idx)} + className="flex cursor-pointer items-center justify-between gap-3" + > +
+ + {entry.type} + + {timeFormatted} + {traceId && ( + + trace:{traceId.slice(0, 8)}... + + )} +
+ +
+ {traceId && ( + + )} + +
+
+ + {isExpanded && ( +
+                          {JSON.stringify(entry.data, null, 2)}
+                        
+ )} +
+ ); + })} +
+ )} +
+ )} +
+
+ ); +}; + +export default SearchAndWatcher; diff --git a/otel-ui/src/components/ServiceGraph.tsx b/otel-ui/src/components/ServiceGraph.tsx new file mode 100644 index 0000000..85df435 --- /dev/null +++ b/otel-ui/src/components/ServiceGraph.tsx @@ -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(null); + + const { nodes, edges, width, height } = useMemo(() => { + // 1. Map spans by ID for parent lookup + const spanMap = new Map(); + 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(); + uniqueServices.forEach((s) => inDegree.set(s, 0)); + edgeMap.forEach((edge) => { + inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1); + }); + + const layers = new Map(); + // 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(); + 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(); + + 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 ( +
+ No service nodes available to visualize. +
+ ); + } + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + return ( +
+
+
+ + + Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links) + +
+
+ + Healthy + + + Errors + + {selectedService && ( + + )} +
+
+ +
+ + + + + + + + + + + + + + + + + + + {/* 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 ( + + {/* Background path for hit testing */} + + {/* Visible animated curve */} + + {/* Edge Call Badge */} + + + + {edge.callCount}x · {formatDuration(edge.avgDuration)} + + + + ); + })} + + {/* Render Service Nodes */} + {nodes.map((node) => { + const isSelected = selectedService === node.name; + const isHovered = hoveredNode === node.name; + const hasError = node.errorCount > 0; + + return ( + onSelectService?.(isSelected ? null : node.name)} + onMouseEnter={() => setHoveredNode(node.name)} + onMouseLeave={() => setHoveredNode(null)} + className="cursor-pointer" + > + {/* Node Box */} + + + {/* Left Accent Stripe */} + + + {/* Service Name Header */} + + + {node.name.length > 20 ? `${node.name.slice(0, 18)}...` : node.name} + + + + {/* Spans Count & Latency Pills */} + + + Spans: {node.spanCount} + + + Time: {formatDuration(node.totalDuration)} + + + + {/* Status Badges */} + + {hasError ? ( + + + + {node.errorCount} Error{node.errorCount > 1 ? "s" : ""} + + + ) : ( + + + + Healthy + + + )} + {isSelected && ( + + + + Active + + + )} + + + ); + })} + +
+ + {nodes.length === 1 && ( +
+ This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made. +
+ )} +
+ ); +}; diff --git a/otel-ui/src/components/SetupPanel.tsx b/otel-ui/src/components/SetupPanel.tsx new file mode 100644 index 0000000..d1672af --- /dev/null +++ b/otel-ui/src/components/SetupPanel.tsx @@ -0,0 +1,112 @@ +import { useState } from "react" +import { Check, Copy, ExternalLink, FileText, Globe } from "lucide-react" +import { useObservabilityData } from "../hooks/useObservabilityData" + +const scriptSnippet = ` +` + +const CopyButton = ({ text }: { text: string }) => { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +export const SetupPanel = () => { + const { setActiveTab } = useObservabilityData() + return ( +
+
+
+ + + Browser instrumentation (otel-rum.js) + + +
+

+ Drop this into any page to capture uncaught errors, unhandled rejections and failed + resource loads as OTLP logs. Errors show up here under the{" "} + Issues tab tagged{" "} + Frontend Error, and correlate with + backend traces via traceHeaders(). +

+
+          {scriptSnippet}
+        
+
+ + + View raw script at /static/otel-rum.js + + • + +
+
+ +
+ + + Agent instructions + +

+ 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. +

+ + + Open /agents_example.md + +
+
+ ) +} + +export default SetupPanel diff --git a/otel-ui/src/components/Sidebar.tsx b/otel-ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..1acae66 --- /dev/null +++ b/otel-ui/src/components/Sidebar.tsx @@ -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 ( + + ); +}; diff --git a/otel-ui/src/components/StructuredLogTerminal.tsx b/otel-ui/src/components/StructuredLogTerminal.tsx new file mode 100644 index 0000000..3710e41 --- /dev/null +++ b/otel-ui/src/components/StructuredLogTerminal.tsx @@ -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(null); + const [streamFilter, setStreamFilter] = useState(""); + const [wrapText, setWrapText] = useState(false); + const [cleared, setCleared] = useState(false); + + // Severity filters + const [selectedSeverities, setSelectedSeverities] = useState>({ + 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(); + 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 = {}; + 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 ( +
+ {/* LEFT PANEL: Faceted Filter Breakdown (3 Cols) */} + + + {/* RIGHT PANEL: High-Density Structured Live Log Stream (9 Cols) */} +
+ {/* Stream Controls Bar */} +
+
+
+ + filter_list + + 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" + /> +
+
+ +
+ + +
+ + + + +
+
+ + {/* Log Terminal Headers */} +
+
Timestamp (UTC)
+
Level
+
Service & Trace
+
Log Message & Structured Payload
+
+ + {/* Realtime Log Entries List */} +
+ {visibleRecords.length === 0 ? ( +
+ {cleared + ? "Stream buffer cleared. Waiting for new telemetry records..." + : "No log records match the current filters."} +
+ ) : ( + 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 ( +
+
setExpandedRowId(isExpanded ? null : rowId)} + className="grid grid-cols-12 px-4 py-2 items-start cursor-pointer group" + > +
+ {isoTime} +
+ +
+ + {sevLabel} + +
+ +
+ {record.serviceName} + {record.traceId && ( +
+ + link + + +
+ )} +
+ +
+
+ {num >= 17 && ( + Exception: + )} + {bodyMessage} +
+ + + {isExpanded ? "unfold_less" : "unfold_more"} + +
+
+ + {/* Structured Payload Drawer (Expanded) */} + {isExpanded && ( +
+
+ + OpenTelemetry Attributes & Resource Scope + + {record.traceId && ( + + )} +
+ + {/* Attributes Grid */} +
+ {record.spanId && ( +
+ span_id + + {record.spanId} + +
+ )} + {record.traceId && ( +
+ trace_id + {record.traceId} +
+ )} + {record.scope && ( +
+ scope + {record.scope} +
+ )} +
+ service.name + {record.serviceName} +
+
+ + {/* Raw JSON block */} +
+                        
+                          {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,
+                          )}
+                        
+                      
+
+ )} +
+ ); + }) + )} +
+ + {/* Log Terminal Footer Status */} +
+
+ + Showing {visibleRecords.length} of {allRecords.length} matched events + + + Buffer: Healthy +
+ +
+ + Auto-scroll: LIVE STREAM +
+
+
+
+ ); +}; diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx new file mode 100644 index 0000000..c65e1ce --- /dev/null +++ b/otel-ui/src/components/Tabs.tsx @@ -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 = { + 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(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 ( +
+ {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 ( +
+ {/* The Main Log Row */} +
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]" + > +
+ + {/* Timestamp */} + + {formatTimestamp(record.timeUnixNano)} + + + {/* Service Badge */} + {(() => { + const sName = getRecordServiceName(record.attributes ?? []) + const palette = getServicePalette(sName) + return ( + + {sName} + + ) + })()} + + {/* Severity Badge */} + + {severity.display} + + + {/* Log Message */} + + {bodyMessage} + + + {/* Action Icons */} +
+ {record.traceId && ( + + )} + {isExpanded ? : } +
+
+ + {/* Expansion Content */} + {isExpanded && ( +
+
+ {(record.attributes ?? []).length > 0 && ( +
+

Attributes

+
+ {(record.attributes ?? []).map(attr => ( +
+ {attr.key}: + {formatAttributeValue(attr.value)} +
+ ))} +
+
+ )} + {(record.resourceAttributes ?? []).length > 0 && ( +
+

Resource

+
+ {(record.resourceAttributes ?? []).map(attr => ( +
+ {attr.key}: + {formatAttributeValue(attr.value)} +
+ ))} +
+
+ )} +
+
+
+ Scope: {record.scope || "unknown"} + {record.spanId && Span ID: {record.spanId}} + {record.traceId && Trace ID: {record.traceId}} +
+ + {record.traceId && ( + + )} +
+
+ )} +
+ ) + })} +
+ ) +} + +import { StructuredLogTerminal } from "./StructuredLogTerminal" + +const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => { + return +} + +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 ( +
+ + + + {tabItems.map(({ value, label, Icon, count }) => ( + +
+ + + {count !== undefined && ( + + {numberFormatter.format(count)} + + )} + + ))} + + + {activeTab === "tab0" && ( + + + + )} + + + {activeTab === "tab1" && } + + + {activeTab === "tab8" && } + + + {activeTab === "tab9" && } + + + {activeTab === "tab2" && } + + + {activeTab === "tab3" && } + + + {activeTab === "tab7" && } + + + {activeTab === "tab4" && } + + {statsAvailable && ( + + {activeTab === "tab5" && } + + )} + + {activeTab === "tab6" && } + + +
+ ) + +} diff --git a/otel-ui/src/components/TelemetryFilters.tsx b/otel-ui/src/components/TelemetryFilters.tsx new file mode 100644 index 0000000..1342d47 --- /dev/null +++ b/otel-ui/src/components/TelemetryFilters.tsx @@ -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 ( +
+
+ {/* Service Multi-Select */} +
+
+ + + Service Focus + +
+
+ {uniqueServices.map((service) => { + const isActive = selectedServices.has(service) + const palette = getServicePalette(service) + return ( + + ) + })} + {uniqueServices.length === 0 && ( + + Listening for service signals... + + )} +
+
+ + {/* Attribute Exclusions */} +
+
+ + + Active Noise Suppression + +
+
+ {hasExcludedKeys ? ( + Object.entries(excludedAttributes).map(([key, valueSet]) => ( +
+
+ + {key} + + +
+
+ {Array.from(valueSet).map((value) => ( + + ))} +
+
+ )) + ) : ( +
+

+ No active attribute exclusions. +

+

+ Click attributes in details to suppress chatty items +

+
+ )} +
+
+
+
+ ) +} diff --git a/otel-ui/src/components/TopHeader.tsx b/otel-ui/src/components/TopHeader.tsx new file mode 100644 index 0000000..b5207da --- /dev/null +++ b/otel-ui/src/components/TopHeader.tsx @@ -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(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 ( +
+ {/* Search and Time Range */} +
+ {/* Global Search Bar */} +
+ + search + + 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" + /> +
+ + ⌘ + + + K + +
+
+ + {/* Real Time Window Selector */} +
+ + schedule + + {(["15m", "1h", "6h", "24h", "all"] as const).map((w) => ( + + ))} +
+
+ + {/* Action Buttons & Streaming Controls */} +
+ {/* Real Streaming Pause/Resume Button */} + + + {/* All vs Errors Toggle */} +
+ + +
+ + {/* Slow Spans Toggle (>500ms) */} + + + + +
+ + {/* Active Issues / Incident Counter Button */} + + + {/* User Account / Auth */} + {user ? ( +
+
+ {(user.name || user.email || user.sub).charAt(0).toUpperCase()} +
+
+ + {user.name || user.email?.split("@")[0] || user.sub} + + +
+
+ ) : ( + + )} +
+
+ ); +}; diff --git a/otel-ui/src/components/TraceVisualization.tsx b/otel-ui/src/components/TraceVisualization.tsx new file mode 100644 index 0000000..d6d0536 --- /dev/null +++ b/otel-ui/src/components/TraceVisualization.tsx @@ -0,0 +1,1866 @@ +import { useMemo, useState, useRef, useEffect } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + Sparkles, + ChevronDown, + ChevronRight, + ChevronLeft, + ScrollText, + Activity, + AlertTriangle, + Layers, + Search, + X, + Copy, + Check, + SlidersHorizontal, + TableProperties, + ArrowUpDown, + ListFilter, +} from "lucide-react"; +import type { TraceData, Span } from "../lib/types"; +import { formatDuration, formatRelativeTime } from "../lib/timeUtils"; +import { formatAttributeValue, getServicePalette } from "../lib/otel-utils"; +import { useLogsForTraceId } from "../hooks/useLogsForTraceId"; +import { useObservabilityData } from "../hooks/useObservabilityData"; +import { LogEntryView } from "./Tabs"; +import { ServiceGraph, type GraphSpan } from "./ServiceGraph"; + +export type ProcessedSpan = Span & { + service: string; + startTime: number; + endTime: number; + duration: number; + formattedAttributes: Record; + children?: ProcessedSpan[]; + logCount: number; + hasError: boolean; +}; + +export type ProcessedTrace = { + id: string; + name: string; + spans: ProcessedSpan[]; + startTime: number; + endTime: number; + duration: number; + services: string[]; + hasError: boolean; + errorCount: number; +}; + +// Flattened item for virtualized tree rendering +interface FlattenedSpanItem { + span: ProcessedSpan; + depth: number; + hasChildren: boolean; + isCollapsed: boolean; + offsetPercent: number; + widthPercent: number; +} + +const buildTraceTree = (spans: ProcessedSpan[]): ProcessedSpan[] => { + const spanMap: Record = {}; + spans.forEach((span) => { + spanMap[span.spanId] = { ...span, children: [] }; + }); + + const rootSpans: ProcessedSpan[] = []; + spans.forEach((span) => { + const processed = spanMap[span.spanId]; + if (span.parentSpanId && spanMap[span.parentSpanId]) { + spanMap[span.parentSpanId].children!.push(processed); + } else { + rootSpans.push(processed); + } + }); + + const sortChildren = (node: ProcessedSpan) => { + node.children?.sort((a, b) => a.startTime - b.startTime); + node.children?.forEach(sortChildren); + }; + rootSpans.forEach(sortChildren); + rootSpans.sort((a, b) => a.startTime - b.startTime); + + return rootSpans; +}; + +// Flatten the tree into an array of visible items based on collapsed state +const flattenSpanTree = ( + nodes: ProcessedSpan[], + collapsedSet: Set, + traceMinTime: number, + traceTotalRange: number, + depth = 0, +): FlattenedSpanItem[] => { + const result: FlattenedSpanItem[] = []; + + for (const span of nodes) { + const hasChildren = Boolean(span.children && span.children.length > 0); + const isCollapsed = collapsedSet.has(span.spanId); + + const offsetPercent = + traceTotalRange === 0 ? 0 : ((span.startTime - traceMinTime) / traceTotalRange) * 100; + const widthPercent = + traceTotalRange === 0 ? 100 : (span.duration / traceTotalRange) * 100; + + result.push({ + span, + depth, + hasChildren, + isCollapsed, + offsetPercent: Math.max(0, Math.min(offsetPercent, 100)), + widthPercent: Math.max(0.5, Math.min(widthPercent, 100 - offsetPercent)), + }); + + if (hasChildren && !isCollapsed && span.children) { + result.push( + ...flattenSpanTree( + span.children, + collapsedSet, + traceMinTime, + traceTotalRange, + depth + 1, + ), + ); + } + } + + return result; +}; + +const getOperationDisplayName = (span: ProcessedSpan) => { + let name = span.name; + if (!name || name === "/" || name === "GET /") { + const method = span.formattedAttributes["http.method"]; + const target = span.formattedAttributes["http.target"]; + const route = span.formattedAttributes["http.route"]; + const urlPath = span.formattedAttributes["url.path"]; + const rpcMethod = span.formattedAttributes["rpc.method"]; + + if (route) name = route; + else if (method && target) name = `${method} ${target}`; + else if (rpcMethod) name = rpcMethod; + else if (urlPath) name = urlPath; + else if (method) name = method; + } + return name || "span"; +}; + +// Span Detail Inspector Drawer +const SpanInspector = ({ + span, + onClose, + traceTotalRange, +}: { + span: ProcessedSpan; + onClose: () => void; + traceTotalRange: number; +}) => { + const [tab, setTab] = useState<"overview" | "attributes" | "events" | "logs">("overview"); + const [copiedKey, setCopiedKey] = useState(null); + const [attrSearch, setAttrSearch] = useState(""); + const { toggleExcludedValue, setSearchQuery, setActiveTab } = useObservabilityData(); + + const copyToClipboard = (text: string, key: string) => { + navigator.clipboard.writeText(text); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 1500); + }; + + const logs = useLogsForTraceId(span.traceId); + const spanLogs = useMemo(() => { + return (logs ?? []).filter((log) => + (log.scopeLogs ?? []).some((sl) => + (sl.logRecords ?? []).some((lr) => lr.spanId === span.spanId), + ), + ); + }, [logs, span.spanId]); + + const palette = getServicePalette(span.service); + const attributeEntries = Object.entries(span.formattedAttributes); + const filteredAttributes = useMemo(() => { + if (!attrSearch.trim()) return attributeEntries; + const query = attrSearch.toLowerCase().trim(); + return attributeEntries.filter( + ([k, v]) => k.toLowerCase().includes(query) || v.toLowerCase().includes(query), + ); + }, [attributeEntries, attrSearch]); + + const percentageOfTrace = + traceTotalRange > 0 ? ((span.duration / traceTotalRange) * 100).toFixed(1) : "100"; + + return ( + + ); +}; + +// Virtualized Span Waterfall View +interface VirtualizedSpanWaterfallProps { + trace: ProcessedTrace; + selectedSpanId: string | null; + onSelectSpan: (spanId: string) => void; + filterService: string | null; +} + +const VirtualizedSpanWaterfall = ({ + trace, + selectedSpanId, + onSelectSpan, + filterService, +}: VirtualizedSpanWaterfallProps) => { + const [collapsedSpans, setCollapsedSpans] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [errorsOnly, setErrorsOnly] = useState(false); + const [minDurationMsFilter, setMinDurationMsFilter] = useState(0); + const parentRef = useRef(null); + + // Build tree + const rootSpans = useMemo(() => buildTraceTree(trace.spans), [trace.spans]); + + // Flatten tree based on collapse state + const flattenedItems = useMemo(() => { + return flattenSpanTree( + rootSpans, + collapsedSpans, + trace.startTime, + trace.duration, + ); + }, [rootSpans, collapsedSpans, trace.startTime, trace.duration]); + + // Apply search & service filter + const visibleItems = useMemo(() => { + return flattenedItems.filter((item) => { + if (filterService && item.span.service !== filterService) return false; + if (errorsOnly && !item.span.hasError) return false; + if (minDurationMsFilter > 0) { + const durMs = item.span.duration / 1_000_000; + if (durMs < minDurationMsFilter) return false; + } + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + const matchesName = item.span.name.toLowerCase().includes(query); + const matchesService = item.span.service.toLowerCase().includes(query); + const matchesAttrs = Object.values(item.span.formattedAttributes).some((v) => + v.toLowerCase().includes(query), + ); + if (!matchesName && !matchesService && !matchesAttrs) return false; + } + return true; + }); + }, [flattenedItems, filterService, errorsOnly, minDurationMsFilter, searchQuery]); + + + // Virtualizer + const rowVirtualizer = useVirtualizer({ + count: visibleItems.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 38, + overscan: 10, + }); + + const toggleCollapse = (spanId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsedSpans((prev) => { + const next = new Set(prev); + if (next.has(spanId)) next.delete(spanId); + else next.add(spanId); + return next; + }); + }; + + const expandAll = () => setCollapsedSpans(new Set()); + const collapseAll = () => { + const allParentIds = new Set(); + trace.spans.forEach((s) => { + if (s.children && s.children.length > 0) allParentIds.add(s.spanId); + }); + setCollapsedSpans(allParentIds); + }; + + // Time ruler intervals: 0%, 25%, 50%, 75%, 100% + const rulerTicks = [0, 0.25, 0.5, 0.75, 1]; + + return ( +
+ {/* Waterfall Toolbar */} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Filter spans..." + className="h-8 w-44 sm:w-56 rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + + + +
+ + +
+
+ + +
+ + + {visibleItems.length} of {trace.spans.length} Spans + +
+
+ + {/* Synchronized Time Ruler Header */} +
+
+ Hierarchy & Operation + Duration +
+ +
+ {rulerTicks.map((pct) => ( + + {formatDuration(trace.duration * pct)} + + ))} +
+
+ + {/* Virtualized Span Rows Container */} +
+ {visibleItems.length === 0 ? ( +
+ No spans match the current filters. +
+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = visibleItems[virtualRow.index]; + const isSelected = selectedSpanId === item.span.spanId; + const palette = getServicePalette(item.span.service); + + return ( +
onSelectSpan(item.span.spanId)} + className={`absolute left-0 top-0 w-full grid grid-cols-12 items-center px-4 cursor-pointer transition-colors ${ + isSelected + ? "bg-sky-500/15 border-l-4 border-sky-400 shadow-inner" + : "hover:bg-white/[0.03]" + }`} + style={{ + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} + > + {/* Left Column: Tree Indent, Chevrons, Service Pill, Name, Duration */} +
+
+ {/* Indentation with tree guide */} +
+ {item.depth > 0 && ( +
+ )} +
+ + {/* Expand / Collapse Button */} + {item.hasChildren ? ( + + ) : ( +
+ )} + + {/* Service Tag */} + + {item.span.service} + + + {/* Operation Name */} + + {getOperationDisplayName(item.span)} + + + {/* Error Dot Indicator */} + {item.span.hasError && ( + + )} + + {/* Correlated Logs Count */} + {item.span.logCount > 0 && ( + + {item.span.logCount} logs + + )} +
+ + + {formatDuration(item.span.duration)} + +
+ + {/* Right Column: Waterfall Bar & Time Grid */} +
+ {/* Synchronized Vertical Guide Lines */} + {rulerTicks.map((pct) => ( +
+ ))} + + {/* Waterfall Span Bar */} +
+ {item.widthPercent > 12 && ( + + {formatDuration(item.span.duration)} + + )} +
+
+
+ ); + })} +
+ )} +
+
+ ); +}; + +// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer +const SingleTraceView = ({ + trace, +}: { + trace: ProcessedTrace; +}) => { + const [selectedSpanId, setSelectedSpanId] = useState(null); + const [viewMode, setViewMode] = useState<"waterfall" | "graph">("waterfall"); + const [filterService, setFilterService] = useState(null); + + const selectedSpan = useMemo(() => { + if (!selectedSpanId) return null; + return trace.spans.find((s) => s.spanId === selectedSpanId) || null; + }, [trace.spans, selectedSpanId]); + + // Service distribution breakdown + const serviceBreakdown = useMemo(() => { + const serviceTotals = new Map(); + trace.spans.forEach((s) => { + serviceTotals.set(s.service, (serviceTotals.get(s.service) || 0) + s.duration); + }); + + const sumDurations = Array.from(serviceTotals.values()).reduce((a, b) => a + b, 0) || 1; + return Array.from(serviceTotals.entries()).map(([service, dur]) => ({ + service, + duration: dur, + percentage: Math.round((dur / sumDurations) * 100), + palette: getServicePalette(service), + })); + }, [trace.spans]); + + // Transform spans for the Service Graph + const graphSpans: GraphSpan[] = useMemo(() => { + return trace.spans.map((s) => ({ + spanId: s.spanId, + parentSpanId: s.parentSpanId, + service: s.service, + duration: s.duration, + hasError: s.hasError, + })); + }, [trace.spans]); + + const startDate = new Date(trace.startTime / 1_000_000); + + return ( +
+ {/* Trace Card Header */} +
+
+
+ +
+ +
+
+

+ {trace.name} +

+ + {trace.hasError ? `${trace.errorCount} Error(s)` : "Healthy"} + +
+
+ {trace.id.slice(0, 12)}... + • + {startDate.toLocaleTimeString()} + ({formatRelativeTime(startDate.getTime())}) +
+
+
+ + {/* Trace Stats Badges & View Switcher */} +
+
+
+ + Total Spans + + {trace.spans.length} +
+
+ + Duration + + + {formatDuration(trace.duration)} + +
+
+ + {/* Mode Switcher: Waterfall vs Service Graph */} +
+ + +
+
+
+ + {/* Visual Service Distribution Timeline Bar */} +
+
+ Service Time Distribution +
+ {serviceBreakdown.map((item) => ( + + + {item.service} ({item.percentage}%) + + ))} +
+
+ + {/* Multi-segment Progress Bar */} +
+ {serviceBreakdown.map((item) => ( +
+ ))} +
+
+ + {/* Main View: either Service Call Graph or Virtualized Waterfall */} + {viewMode === "graph" ? ( + { + setFilterService(srv); + if (srv) setViewMode("waterfall"); + }} + /> + ) : ( +
+ {/* Virtualized Waterfall Table */} +
+ setSelectedSpanId((prev) => (prev === id ? null : id))} + filterService={filterService} + /> +
+ + {/* Span Detail Inspector Panel */} + {selectedSpan && ( +
+ setSelectedSpanId(null)} + traceTotalRange={trace.duration} + /> +
+ )} +
+ )} +
+ ); +}; + +// Searchable Trace Picker Dropdown (used in Waterfall view) +const TracePickerDropdown = ({ + traces, + activeTrace, + onSelect, +}: { + traces: ProcessedTrace[]; + activeTrace: ProcessedTrace; + onSelect: (id: string) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleOutside); + return () => document.removeEventListener("mousedown", handleOutside); + } + }, [isOpen]); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + if (isOpen) { + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + } + }, [isOpen]); + + const filtered = useMemo(() => { + if (!search.trim()) return traces; + const q = search.toLowerCase().trim(); + return traces.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.id.toLowerCase().includes(q) || + t.services.some((s) => s.toLowerCase().includes(q)), + ); + }, [traces, search]); + + const rootService = activeTrace.spans[0]?.service || activeTrace.services[0] || "unknown"; + const palette = getServicePalette(rootService); + + return ( +
+ + + {isOpen && ( +
+
+ + setSearch(e.target.value)} + placeholder={`Search ${traces.length} traces...`} + className="w-full h-8 rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none" + /> + {search && ( + + )} +
+ +
+ {filtered.length === 0 ? ( +
+ No traces match “{search}” +
+ ) : ( + filtered.map((t) => { + const isSelected = t.id === activeTrace.id; + const srv = t.spans[0]?.service || t.services[0] || "unknown"; + const pal = getServicePalette(srv); + + return ( + + ); + }) + )} +
+
+ )} +
+ ); +}; + +// Trace Directory Table View with Search, Filters, Comparative Duration & Sorting +const TraceDirectoryTable = ({ + traces, + activeTraceId, + onSelectTrace, + searchQuery, + onSearchChange, + filter, + onFilterChange, + serviceFilter, + onServiceFilterChange, + sortBy, + onSortByChange, + allServices, + onSwitchToWaterfall, +}: { + traces: ProcessedTrace[]; + activeTraceId: string | null; + onSelectTrace: (id: string) => void; + searchQuery: string; + onSearchChange: (q: string) => void; + filter: "all" | "errors" | "slow"; + onFilterChange: (f: "all" | "errors" | "slow") => void; + serviceFilter: string; + onServiceFilterChange: (s: string) => void; + sortBy: "recent" | "duration-desc" | "duration-asc" | "errors" | "spans"; + onSortByChange: (s: any) => void; + allServices: string[]; + onSwitchToWaterfall: () => void; +}) => { + const [copiedId, setCopiedId] = useState(null); + + const copyTraceId = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(id); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 1500); + }; + + const maxDuration = useMemo(() => { + return Math.max(...traces.map((t) => t.duration), 1); + }, [traces]); + + const errorCount = useMemo(() => { + return traces.filter((t) => t.hasError).length; + }, [traces]); + + return ( +
+ {/* Directory Header & Toolbar */} +
+
+
+ +
+
+
+

Traces Explorer

+ + {traces.length} traces + + {errorCount > 0 && ( + + {errorCount} with errors + + )} +
+

+ Browse, search, and inspect distributed trace execution flows +

+
+
+ + +
+ + {/* Filter and Search Controls */} +
+
+
+ + onSearchChange(e.target.value)} + placeholder="Search by name, service, route, or traceId..." + className="h-8 w-full rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none" + /> + {searchQuery && ( + + )} +
+
+ +
+ {/* Quick Filters */} +
+ + + +
+ + {/* Service Filter */} + {allServices.length > 1 && ( +
+ + +
+ )} + + {/* Sort Dropdown */} +
+ + +
+
+
+ + {/* Traces Table */} + {traces.length === 0 ? ( +
+ No traces match your filter or search criteria. +
+ ) : ( +
+ + + + + + + + + + + + + + + {traces.map((trace) => { + const isSelected = trace.id === activeTraceId; + const rootService = trace.spans[0]?.service || trace.services[0] || "unknown"; + const palette = getServicePalette(rootService); + const durationPercent = Math.max( + 4, + Math.min(100, (trace.duration / maxDuration) * 100), + ); + + return ( + { + onSelectTrace(trace.id); + onSwitchToWaterfall(); + }} + className={`cursor-pointer transition-colors ${ + isSelected + ? "bg-sky-500/15 border-l-4 border-sky-400" + : "hover:bg-white/[0.03]" + }`} + > + + + + + + + + + + + + + + + + + ); + })} + +
StatusTrace / OperationServicesTrace IDSpansDurationTimeAction
+ {trace.hasError ? ( + + + {trace.errorCount} Error{trace.errorCount > 1 ? "s" : ""} + + ) : ( + + OK + + )} + +
+ + {trace.name} + + + root: {trace.spans[0]?.name || trace.name} + +
+
+
+ + {rootService} + + {trace.services.length > 1 && ( + + +{trace.services.length - 1} + + )} +
+
+
+ {trace.id.slice(0, 10)}... + +
+
{trace.spans.length} +
+ + {formatDuration(trace.duration)} + +
+
+
+
+
+
+ + {formatRelativeTime(trace.startTime / 1_000_000)} + + + {new Date(trace.startTime / 1_000_000).toLocaleTimeString()} + +
+
+ +
+
+ )} +
+ ); +}; + +export interface TraceVisualizationProps { + traces: TraceData[] | undefined; +} + +export const TraceVisualization = ({ traces }: TraceVisualizationProps) => { + const safeTraces = traces ?? []; + const { + logCountBySpanId, + activeTraceId, + setActiveTraceId, + selectedTimeRange, + setSelectedTimeRange, + } = useObservabilityData(); + + const [viewMode, setViewMode] = useState<"waterfall" | "directory">("waterfall"); + const [traceSearchQuery, setTraceSearchQuery] = useState(""); + const [traceFilter, setTraceFilter] = useState<"all" | "errors" | "slow">("all"); + const [traceServiceFilter, setTraceServiceFilter] = useState("all"); + const [traceSortBy, setTraceSortBy] = useState< + "recent" | "duration-desc" | "duration-asc" | "errors" | "spans" + >("recent"); + + const orderedTraces: ProcessedTrace[] = useMemo(() => { + const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) => + trace.resourceSpans?.flatMap((resourceSpan) => + resourceSpan.scopeSpans?.flatMap((scopeSpan) => + scopeSpan.spans?.map((spanItem) => { + const { attributes, ...spanDetails } = spanItem; + const formattedAttributes = Object.fromEntries( + attributes?.map(({ key, value }) => [key, formatAttributeValue(value)]) ?? [], + ); + const serviceName = + resourceSpan.resource?.attributes?.find( + (attr) => attr.key === "service.name", + )?.value.stringValue || "unknown-service"; + + const startTime = Number(spanDetails.startTimeUnixNano ?? "0"); + const endTime = Number( + spanDetails.endTimeUnixNano ?? spanDetails.startTimeUnixNano ?? "0", + ); + const duration = Math.max(endTime - startTime, 0); + + const statusCode = formattedAttributes["http.status_code"]; + const isHttpError = Boolean(statusCode && Number(statusCode) >= 400); + const isOtelError = (spanDetails.status as any)?.code === 2; + const hasError = isHttpError || isOtelError; + + const logCount = logCountBySpanId[spanDetails.spanId] || 0; + + return { + ...spanDetails, + attributes, + formattedAttributes, + service: serviceName, + startTime, + endTime, + duration, + logCount, + hasError, + } as ProcessedSpan; + }), + ), + ), + ); + + const uniqueSpans = spanAccumulator.filter( + (span, index, arr) => + arr.findIndex((candidate) => candidate.spanId === span.spanId) === index, + ); + + const traceGroups = uniqueSpans.reduce>((acc, span) => { + if (!acc[span.traceId]) acc[span.traceId] = []; + acc[span.traceId].push(span); + return acc; + }, {}); + + const traceList: ProcessedTrace[] = Object.entries(traceGroups).map(([id, spans]) => { + const orderedSpans = spans.sort((a, b) => a.startTime - b.startTime); + const displayName = + orderedSpans.find((span) => span.name)?.name || `Trace ${id.slice(0, 8)}`; + const startTime = orderedSpans.reduce( + (min, s) => Math.min(min, s.startTime), + Number.POSITIVE_INFINITY, + ); + const endTime = orderedSpans.reduce( + (max, s) => Math.max(max, s.endTime), + Number.NEGATIVE_INFINITY, + ); + const duration = Math.max(endTime - startTime, 0); + const services = Array.from(new Set(orderedSpans.map((s) => s.service))); + const errorCount = orderedSpans.filter((s) => s.hasError).length; + + return { + id, + name: displayName, + spans: orderedSpans, + startTime, + endTime, + duration, + services, + hasError: errorCount > 0, + errorCount, + }; + }); + + // Sort traces by most recent first + traceList.sort((a, b) => b.startTime - a.startTime); + return traceList; + }, [safeTraces, logCountBySpanId]); + + const allServices = useMemo(() => { + const s = new Set(); + orderedTraces.forEach((t) => t.services.forEach((srv) => s.add(srv))); + return Array.from(s).sort(); + }, [orderedTraces]); + + const filteredTraceList = useMemo(() => { + return orderedTraces + .filter((t) => { + if (traceFilter === "errors" && !t.hasError) return false; + if (traceFilter === "slow" && t.duration / 1_000_000 < 200) return false; + if (traceServiceFilter !== "all" && !t.services.includes(traceServiceFilter)) return false; + if (traceSearchQuery.trim()) { + const q = traceSearchQuery.toLowerCase().trim(); + const matchName = t.name.toLowerCase().includes(q); + const matchId = t.id.toLowerCase().includes(q); + const matchService = t.services.some((s) => s.toLowerCase().includes(q)); + if (!matchName && !matchId && !matchService) return false; + } + return true; + }) + .sort((a, b) => { + if (traceSortBy === "duration-desc") return b.duration - a.duration; + if (traceSortBy === "duration-asc") return a.duration - b.duration; + if (traceSortBy === "errors") + return b.errorCount - a.errorCount || b.startTime - a.startTime; + if (traceSortBy === "spans") return b.spans.length - a.spans.length; + return b.startTime - a.startTime; // recent + }); + }, [orderedTraces, traceFilter, traceServiceFilter, traceSearchQuery, traceSortBy]); + + // Set default active trace if none selected or if active trace is outside current filtered list + useEffect(() => { + if (filteredTraceList.length > 0) { + if (!activeTraceId || !filteredTraceList.some((t) => t.id === activeTraceId)) { + setActiveTraceId(filteredTraceList[0].id); + } + } + }, [filteredTraceList, activeTraceId, setActiveTraceId]); + + const currentTraceIndex = useMemo(() => { + return filteredTraceList.findIndex((t) => t.id === activeTraceId); + }, [filteredTraceList, activeTraceId]); + + const handlePrevTrace = () => { + if (currentTraceIndex > 0) { + setActiveTraceId(filteredTraceList[currentTraceIndex - 1].id); + } + }; + + const handleNextTrace = () => { + if (currentTraceIndex < filteredTraceList.length - 1) { + setActiveTraceId(filteredTraceList[currentTraceIndex + 1].id); + } + }; + + if (orderedTraces.length === 0) { + return ( +
+ +
+

No traces match your filters

+

+ {selectedTimeRange + ? "No traces in the focused waveform period." + : "Adjust focus in Global filters or click Reset Configuration."} +

+ {selectedTimeRange && ( + + )} +
+
+ ); + } + + // Active trace or first trace + const activeTrace = + filteredTraceList.find((t) => t.id === activeTraceId) || + orderedTraces.find((t) => t.id === activeTraceId) || + orderedTraces[0]; + + return ( +
+ {/* Waveform Focus Notification Banner if Active */} + {selectedTimeRange && ( +
+
+ + + Focused on waveform period:{" "} + {selectedTimeRange.label} ( + {orderedTraces.length} trace{orderedTraces.length !== 1 ? "s" : ""} active) + +
+ +
+ )} + + {/* View Switcher Bar (Waterfall vs Directory Table) */} +
+
+ {/* Mode Switcher */} +
+ + +
+ + {/* In Waterfall Mode: Prev / Next Stepper & Searchable Dropdown */} + {viewMode === "waterfall" && filteredTraceList.length > 1 && ( + <> +
+ + + {currentTraceIndex >= 0 ? currentTraceIndex + 1 : 1} / {filteredTraceList.length} + + +
+ + {/* Searchable Trace Picker Dropdown */} + setActiveTraceId(id)} + /> + + )} +
+ + {/* Trace Quick Stats Indicator */} +
+ {filteredTraceList.length} traces available +
+
+ + {/* Main View: Directory Table or Waterfall */} + {viewMode === "directory" ? ( + setActiveTraceId(id)} + searchQuery={traceSearchQuery} + onSearchChange={setTraceSearchQuery} + filter={traceFilter} + onFilterChange={setTraceFilter} + serviceFilter={traceServiceFilter} + onServiceFilterChange={setTraceServiceFilter} + sortBy={traceSortBy} + onSortByChange={setTraceSortBy} + allServices={allServices} + onSwitchToWaterfall={() => setViewMode("waterfall")} + /> + ) : ( + + )} +
+ ); +}; + +export default TraceVisualization; + diff --git a/otel-ui/src/components/TrendsVisualizer.tsx b/otel-ui/src/components/TrendsVisualizer.tsx new file mode 100644 index 0000000..a3b3214 --- /dev/null +++ b/otel-ui/src/components/TrendsVisualizer.tsx @@ -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 ( +
+
+ {d.toLocaleString([], { + month: "short", + day: "numeric", + hour: granularity === "hour" ? "2-digit" : undefined, + minute: granularity === "hour" ? "2-digit" : undefined, + hour12: false, + })} +
+ {payload.map((entry: any) => ( +
+ + + {entry.name} + + {entry.value} +
+ ))} +
+ ) +} + +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> = {} + 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 ( +
+ +

Loading trends…

+
+ ) + } + + return ( +
+ {/* Toolbar */} +
+
+ + + Historical Trends + +
+ {rangeOptions.map((h) => ( + + ))} +
+ {services.length > 0 && ( + + )} +
+
+ + {compactFormatter.format(totals.total)} entries ·{" "} + 0 ? "text-rose-400" : "text-emerald-400"}> + {compactFormatter.format(totals.errors)} errors + + + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Error trend */} +
+
+

+ + Entries & errors over time +

+ + per {granularity} + +
+ {errorData.length === 0 ? ( +

+ No data in this range yet. +

+ ) : ( +
+ + + + + + + + + + + + + + label(b, granularity)} + stroke="#475569" + fontSize={10} + tickLine={false} + axisLine={false} + minTickGap={40} + /> + compactFormatter.format(v)} + /> + } /> + + + + + +
+ )} +
+ + {/* Release comparison */} +
+
+

+ + Errors by release +

+ + {releases.length} release{releases.length === 1 ? "" : "s"} · overlay + +
+ {releaseData.length === 0 || releases.length === 0 ? ( +

+ No release-tagged telemetry in this range. Have services send{" "} + service.release{" "} + to compare deploys here. +

+ ) : ( +
+ + + + label(b, "hour")} + stroke="#475569" + fontSize={10} + tickLine={false} + axisLine={false} + minTickGap={40} + /> + compactFormatter.format(v)} + /> + } /> + + {releases.map((rel, i) => ( + + ))} + + +
+ )} +
+ +

+ + Served from the ClickHouse hourly rollup ({service || "all services"}, last {hours}h) +

+
+ ) +} + +export default TrendsVisualizer diff --git a/otel-ui/src/components/ViewErrorBoundary.tsx b/otel-ui/src/components/ViewErrorBoundary.tsx new file mode 100644 index 0000000..0259a21 --- /dev/null +++ b/otel-ui/src/components/ViewErrorBoundary.tsx @@ -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 { + 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) => void + } + } + }).OtelRUM?.last + rum?.captureException(error, { + "exception.type": "ReactErrorBoundary", + "react.component_stack": info.componentStack, + }) + } + + render() { + if (this.state.error) { + return ( +
+ +
+

+ This view hit an unexpected error +

+

+ {this.state.error.message} +

+
+ +
+ ) + } + + return this.props.children + } +} diff --git a/otel-ui/src/components/WaveformHistogram.tsx b/otel-ui/src/components/WaveformHistogram.tsx new file mode 100644 index 0000000..f2745c7 --- /dev/null +++ b/otel-ui/src/components/WaveformHistogram.tsx @@ -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 ( +
+
+
+ + monitoring + +

+ Telemetry Volume & Error Waveform +

+
+ Listening for OTLP traffic... +
+
+ ); + } + + return ( +
+
+
+ + + {totalCount} events in current buffer + + {hasErrorSurge && ( + + + SURGE +{surgePercent}% + + )} + {selectedTimeRange && ( + + + Time Focus Active + + + )} +
+ + {/* Legend */} +
+
+ + ERROR +
+
+ + WARN +
+
+ + INFO +
+
+
+ + {!isCollapsed && ( + <> + {/* Stacked SVG Histogram with Real Traffic */} +
+
+ {buckets.map((b) => { + const isSurgeBar = hasErrorSurge && b.error > 40; + const isSelected = activeBucket?.idx === b.idx; + const hasAnySelected = activeBucket !== null; + + return ( +
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 && ( +
+ + FOCUS + + +
+ )} + + {/* Incident Flag Marker on Real Surge */} + {!isSelected && isSurgeBar && b.idx === 15 && ( +
+ + + SURGE +{surgePercent}% + + +
+ )} + + {/* Error segment */} + {b.error > 0 && ( +
+ )} + + {/* Warn segment */} + {b.warn > 0 && ( +
+ )} + + {/* Info segment */} + {b.info > 0 && ( +
+ )} +
+ ); + })} +
+
+ + {/* Real Time Scale Axes */} +
+ {timeLabels[0]} + {timeLabels[1]} + + {timeLabels[2]} {hasErrorSurge ? "(SURGE)" : ""} + + {timeLabels[3]} +
+ + {/* Time Focus Bar with quick tab navigation and clear button */} + {activeBucket && ( +
+
+ +
+ + Focused Time Period: + + + {activeBucket.timeLabel} + + + ({activeBucket.traceCount} trace events • {activeBucket.logCount} log records + {activeBucket.rawError > 0 ? ` • ${activeBucket.rawError} errors` : ""}) + +
+
+ +
+ + + +
+
+ )} + + )} +
+ ); +}; diff --git a/otel-ui/src/contexts/ObservabilityContextProvider.tsx b/otel-ui/src/contexts/ObservabilityContextProvider.tsx new file mode 100644 index 0000000..b3b7d6e --- /dev/null +++ b/otel-ui/src/contexts/ObservabilityContextProvider.tsx @@ -0,0 +1,799 @@ +"use client" + +import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react" +import { useLocation } from "wouter" +import { tabForPath, pathForTab } from "../lib/routes" +import type { + MetricData, + OtelHistoryMessage, + OtelMessage, + LogEntry, + TraceData, + ResourceSpan, + ScopeSpan, + Span, +} from "../lib/types" +import { ObservabilityContext, type TimeRangeFilter } from "./ObservabilityContextValue" +import { getServiceName, formatAttributeValue, isSpanError } from "../lib/otel-utils" +import { partitionMessages, mergeLogsByTraceId } from "../lib/ingest" +import { authFetch, tokens } from "../lib/auth" + +interface ObservabilityProviderProps { + children: ReactNode +} + +export function ObservabilityProvider({ + children, +}: ObservabilityProviderProps) { + const [logsByTraceId, setLogsByTraceId] = useState< + Record + >({}) + const [traces, setTraces] = useState([]) + const [logs, setLogs] = useState([]) + const [metrics, setMetrics] = useState([]) + + const [isConnected, setIsConnected] = useState(false) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + + // --- Real Streaming / Pause Buffer State --- + const [isStreaming, setIsStreaming] = useState(true) + const isStreamingRef = useRef(true) + const bufferedMessagesRef = useRef([]) + const [bufferedCount, setBufferedCount] = useState(0) + + // --- Issues live update counter --- + const [issuesVersion, setIssuesVersion] = useState(0) + + // --- Latency filter (milliseconds) --- + const [minDurationMs, setMinDurationMs] = useState(0) + + // --- Persistence Keys --- + const SERVICES_STORAGE_KEY = "otel_selected_services" + const ATTRIBUTES_STORAGE_KEY = "otel_excluded_attributes" + const SHOW_ERRORS_STORAGE_KEY = "otel_show_errors_only" + const SEARCH_QUERY_STORAGE_KEY = "otel_search_query" + const TIME_WINDOW_STORAGE_KEY = "otel_time_window" + + // --- Time Window Filter --- + const [timeWindow, setTimeWindow] = useState<"15m" | "1h" | "6h" | "24h" | "all">(() => { + if (typeof window === "undefined") return "15m" + return (localStorage.getItem(TIME_WINDOW_STORAGE_KEY) as any) || "15m" + }) + + // --- Selected Time Period Filter (e.g. from clicking waveform) --- + const [selectedTimeRange, setSelectedTimeRange] = useState(null) + + // --- Filtering State --- + const defaultExcludedAttributes = useCallback((): Record> => ({ + "user_agent.original": new Set([ + "kube-probe/1.33", + "kube-probe/1.31", + "Uptime-Kuma/1.23.11", + ]), + "http.target": new Set(["/healthz", "/readyz", "/livez"]), + "http.host": new Set(["git.k6n.net"]), + "http.user_agent": new Set(["Uptime-Kuma/1.23.11"]), + }), []) + + const [selectedServices, setSelectedServices] = useState>(() => { + if (typeof window === "undefined") return new Set() + const saved = localStorage.getItem(SERVICES_STORAGE_KEY) + return saved ? new Set(JSON.parse(saved)) : new Set() + }) + + const [excludedAttributes, setExcludedAttributes] = useState>>(() => { + if (typeof window === "undefined") return defaultExcludedAttributes() + const saved = localStorage.getItem(ATTRIBUTES_STORAGE_KEY) + if (!saved) return defaultExcludedAttributes() + try { + const parsed = JSON.parse(saved) + const restored: Record> = {} + Object.entries(parsed).forEach(([key, values]) => { + restored[key] = new Set(values as string[]) + }) + return restored + } catch (e) { + console.error("Failed to restore excluded attributes", e) + return defaultExcludedAttributes() + } + }) + + const [showErrorsOnly, setShowErrorsOnly] = useState(() => { + if (typeof window === "undefined") return false + const saved = localStorage.getItem(SHOW_ERRORS_STORAGE_KEY) + return saved === "true" + }) + + const [searchQuery, setSearchQuery] = useState(() => { + if (typeof window === "undefined") return "" + return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) || "" + }) + + // --- Persistence Effect --- + useEffect(() => { + if (typeof window === "undefined") return + localStorage.setItem(SERVICES_STORAGE_KEY, JSON.stringify(Array.from(selectedServices))) + + const serializeable: Record = {} + Object.entries(excludedAttributes).forEach(([key, values]) => { + serializeable[key] = Array.from(values) + }) + localStorage.setItem(ATTRIBUTES_STORAGE_KEY, JSON.stringify(serializeable)) + localStorage.setItem(SHOW_ERRORS_STORAGE_KEY, showErrorsOnly.toString()) + localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, searchQuery) + localStorage.setItem(TIME_WINDOW_STORAGE_KEY, timeWindow) + }, [selectedServices, excludedAttributes, showErrorsOnly, searchQuery, timeWindow]) + + + // --- Selection State --- + const [activeTraceId, setActiveTraceId] = useState(null) + const [activeSpanId, setActiveSpanId] = useState(null) + // Tab state lives in the URL (wouter): /issues, /traces, ... so deep + // links survive the OIDC login redirect and views are shareable. The + // state mirror below keeps every existing setActiveTab call site working; + // it follows the URL on navigation and pushes the URL on programmatic + // setActiveTab calls. + const [location, navigate] = useLocation() + const [activeTab, setActiveTab] = useState( + () => tabForPath(window.location.pathname) ?? "tab0", + ) + + // External navigation (back/forward, manual URL edit, login redirect): + // adopt the tab encoded in the new location. + useEffect(() => { + const tab = tabForPath(location) + if (tab && tab !== activeTab) setActiveTab(tab) + // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is intentionally read stale here; see comment above + }, [location]) + + const setActiveTabWithRoute = useCallback( + (tab: string) => { + setActiveTab(tab) + const path = pathForTab(tab) + if (path && path !== window.location.pathname) navigate(path) + }, + [navigate], + ) + const [filtersExpanded, setFiltersExpanded] = useState(false) + // Trace ids we already have spans for, so selecting a trace that fell + // outside the initial history window (or arrived before this session + // connected) triggers an on-demand GET /api/traces/{traceId} instead of + // silently showing nothing. + const [knownTraceIds, setKnownTraceIds] = useState>(new Set()) + + const toggleService = useCallback((service: string) => { + setSelectedServices(prev => { + const next = new Set(prev) + if (next.has(service)) { + next.delete(service) + } else { + next.add(service) + } + return next + }) + }, []) + + const toggleExcludedValue = useCallback((key: string, value: string) => { + setExcludedAttributes((prev) => { + const currentSet = prev[key] ?? new Set() + const nextSet = new Set(currentSet) + if (nextSet.has(value)) { + nextSet.delete(value) + } else { + nextSet.add(value) + } + const next = { ...prev } + if (nextSet.size === 0) { + delete next[key] + } else { + next[key] = nextSet + } + return next + }) + }, []) + + const removeExcludedValue = useCallback((key: string, value: string) => { + setExcludedAttributes((prev) => { + const current = prev[key] + if (!current || !current.has(value)) { + return prev + } + const nextSet = new Set(current) + nextSet.delete(value) + const next = { ...prev } + if (nextSet.size === 0) { + delete next[key] + } else { + next[key] = nextSet + } + return next + }) + }, []) + + const clearAttributeKey = useCallback((key: string) => { + setExcludedAttributes((prev) => { + if (!prev[key]) { + return prev + } + const next = { ...prev } + delete next[key] + return next + }) + }, []) + + const resetFilters = useCallback(() => { + setSelectedServices(new Set()) + setExcludedAttributes(defaultExcludedAttributes()) + setSearchQuery("") + setSelectedTimeRange(null) + }, [defaultExcludedAttributes]) + + // Flush buffered WebSocket stream entries when resuming + const flushBufferedStream = useCallback(() => { + const queued = bufferedMessagesRef.current + bufferedMessagesRef.current = [] + setBufferedCount(0) + if (queued.length > 0) { + const partitioned = partitionMessages(queued) + if (partitioned.traces.length) { + setTraces((prev) => [...prev, ...partitioned.traces]) + } + if (partitioned.metrics.length) { + setMetrics((prev) => [...prev, ...partitioned.metrics]) + } + if (partitioned.logs.length) { + setLogs((prev) => [...prev, ...partitioned.logs]) + setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) + } + if (partitioned.traceIds.size) { + setKnownTraceIds((prev) => { + const next = new Set(prev) + partitioned.traceIds.forEach((id) => next.add(id)) + return next + }) + } + } + }, []) + + useEffect(() => { + isStreamingRef.current = isStreaming + if (isStreaming && bufferedMessagesRef.current.length > 0) { + flushBufferedStream() + } + }, [isStreaming, flushBufferedStream]) + + // Compute cutoff timestamp in nanoseconds for timeWindow + const cutoffNano = useMemo(() => { + if (timeWindow === "all") return 0n + const now = Date.now() + const windowMs = { + "15m": 15 * 60 * 1000, + "1h": 60 * 60 * 1000, + "6h": 6 * 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "all": 0, + }[timeWindow] + return BigInt(now - windowMs) * 1_000_000n + }, [timeWindow]) + + // Discover all unique services across all signals + const uniqueServices = useMemo(() => { + const services = new Set() + + // Check logs + logs.forEach(log => { + services.add(getServiceName(log.resource?.attributes ?? [])) + ;(log.scopeLogs ?? []).forEach(sl => (sl.logRecords ?? []).forEach(record => { + services.add(getServiceName(log.resource?.attributes ?? [], record.attributes ?? [])) + })) + }) + + // Check traces + traces.forEach(trace => { + trace.resourceSpans?.forEach(resourceSpan => { + services.add(getServiceName(resourceSpan.resource?.attributes ?? [])) + resourceSpan.scopeSpans?.forEach(ss => { + ss.spans?.forEach(s => { + services.add(getServiceName(resourceSpan.resource?.attributes ?? [], s.attributes ?? [])) + }) + }) + }) + }) + + // Check metrics + metrics.forEach(metric => { + metric.resourceMetrics?.forEach(rm => { + services.add(getServiceName(rm.resource?.attributes ?? [])) + }) + }) + + return Array.from(services).sort().filter(s => s !== "unknown-service") + }, [logs, traces, metrics]) + + // --- Base Filtered Data (Global Filters: Service, TimeWindow, Search, Excluded Attributes) --- + const unfocusedLogs = useMemo(() => { + return logs.map(log => { + const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => { + const filteredRecords = (sl.logRecords ?? []).filter(record => { + const serviceName = getServiceName(log.resource?.attributes ?? [], record.attributes ?? []) + // Filter by service + if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return false + + // Filter by time window + if (cutoffNano > 0n) { + const tNano = BigInt(record.timeUnixNano || "0") + if (tNano > 0n && tNano < cutoffNano) return false + } + + // Filter by attributes + const recordAttrs = Object.fromEntries((record.attributes ?? []).map(a => [a.key, formatAttributeValue(a.value)])) + const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => { + const val = recordAttrs[key] + return val && values.has(val) + }) + if (isExcluded) return false + + if (showErrorsOnly) { + return (record.severityNumber ?? 0) >= 13 // 13 is Warn, 17 is Error + } + + if (searchQuery) { + const query = searchQuery.toLowerCase() + const matchInBody = record.body?.stringValue?.toLowerCase()?.includes(query) ?? false + const matchInAttrs = record.attributes?.some(a => + a.key.toLowerCase()?.includes(query) || + formatAttributeValue(a.value).toLowerCase()?.includes(query) + ) ?? false + const matchInServiceName = serviceName.toLowerCase()?.includes(query) ?? false + const matchInTrace = (record.traceId || "").toLowerCase().includes(query) + if (!matchInBody && !matchInAttrs && !matchInServiceName && !matchInTrace) return false + } + + return true + }) + if (filteredRecords.length === 0) return null + return { ...sl, logRecords: filteredRecords } + }).filter(Boolean) + + if (filteredScopeLogs.length === 0) return null + return { ...log, scopeLogs: filteredScopeLogs as any } + }).filter(Boolean) as LogEntry[] + }, [logs, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano]) + + const unfocusedTraces = useMemo(() => { + return traces.map(trace => { + const filteredResourceSpans = (trace.resourceSpans?.map(rs => { + const serviceName = getServiceName(rs.resource?.attributes ?? []) + // Filter by service + if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null + + const filteredScopeSpans = (rs.scopeSpans?.map(ss => { + const filteredSpans = ss.spans?.filter(span => { + // Filter by time window + if (cutoffNano > 0n) { + const tNano = BigInt(span.startTimeUnixNano || "0") + if (tNano > 0n && tNano < cutoffNano) return false + } + + // Filter by latency threshold (minDurationMs) + if (minDurationMs > 0) { + const start = Number(span.startTimeUnixNano || "0") + const end = Number(span.endTimeUnixNano || span.startTimeUnixNano || "0") + const durMs = (end - start) / 1_000_000 + if (durMs < minDurationMs) return false + } + + // 1. Excluded attributes + const spanAttrs = Object.fromEntries(span.attributes?.map(a => [a.key, formatAttributeValue(a.value)]) ?? []) + const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => { + const val = spanAttrs[key] + return val && values.has(val) + }) + if (isExcluded) return false + + // 2. Search query + if (searchQuery) { + const query = searchQuery.toLowerCase() + const matchInName = (span.name || "").toLowerCase()?.includes(query) ?? false + const matchInTrace = (span.traceId || "").toLowerCase().includes(query) + const matchInAttrs = span.attributes?.some(a => + a.key.toLowerCase()?.includes(query) || + formatAttributeValue(a.value).toLowerCase()?.includes(query) + ) ?? false + if (!matchInName && !matchInAttrs && !matchInTrace) return false + } + + return true + }) + if (!filteredSpans || filteredSpans.length === 0) return null + return { ...ss, spans: filteredSpans } + }).filter(Boolean) as ScopeSpan[]) || [] + + if (!filteredScopeSpans || filteredScopeSpans.length === 0) return null + return { ...rs, scopeSpans: filteredScopeSpans } + }).filter(Boolean) as ResourceSpan[]) || [] + + if (!filteredResourceSpans || filteredResourceSpans.length === 0) return null + + if (showErrorsOnly) { + const hasError = filteredResourceSpans?.some((rs: ResourceSpan) => + rs?.scopeSpans?.some((ss: ScopeSpan) => + ss?.spans?.some((span: Span) => isSpanError(span)) + ) + ) + if (!hasError) return null + } + + return { ...trace, resourceSpans: filteredResourceSpans } + }).filter(Boolean) as TraceData[] + }, [traces, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano, minDurationMs]) + + // --- Selected Time Period Filtering (e.g. from waveform section click) --- + const filteredLogs = useMemo(() => { + if (!selectedTimeRange) return unfocusedLogs + const { startMs, endMs } = selectedTimeRange + return unfocusedLogs.map(log => { + const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => { + const filteredRecords = (sl.logRecords ?? []).filter(record => { + const tMs = Number(BigInt(record.timeUnixNano || "0") / 1_000_000n) + return tMs >= startMs && tMs <= endMs + }) + if (filteredRecords.length === 0) return null + return { ...sl, logRecords: filteredRecords } + }).filter(Boolean) + + if (filteredScopeLogs.length === 0) return null + return { ...log, scopeLogs: filteredScopeLogs as any } + }).filter(Boolean) as LogEntry[] + }, [unfocusedLogs, selectedTimeRange]) + + const filteredTraces = useMemo(() => { + if (!selectedTimeRange) return unfocusedTraces + const { startMs, endMs } = selectedTimeRange + return unfocusedTraces.filter(trace => { + return (trace.resourceSpans ?? []).some(rs => + (rs.scopeSpans ?? []).some(ss => + (ss.spans ?? []).some(span => { + const spanStartMs = Number(BigInt(span.startTimeUnixNano || "0") / 1_000_000n) + const spanEndMs = Number(BigInt(span.endTimeUnixNano || span.startTimeUnixNano || "0") / 1_000_000n) + return spanStartMs <= endMs && spanEndMs >= startMs + }) + ) + ) + }) + }, [unfocusedTraces, selectedTimeRange]) + + const logCountBySpanId = useMemo(() => { + const counts: Record = {} + filteredLogs.forEach(log => { + ;(log.scopeLogs ?? []).forEach(sl => { + ;(sl.logRecords ?? []).forEach(record => { + if (record.spanId) { + counts[record.spanId] = (counts[record.spanId] || 0) + 1 + } + }) + }) + }) + return counts + }, [filteredLogs]) + + const filteredMetrics = useMemo(() => { + return metrics.map(data => { + const filteredResourceMetrics = data.resourceMetrics?.map(rm => { + const serviceName = rm.resource?.attributes?.find(attr => attr.key === "service.name")?.value.stringValue || "unknown-service" + // Filter by service + if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null + + const filteredScopeMetrics = rm.scopeMetrics?.map(sm => { + const filteredMetricsList = sm.metrics?.filter(_m => { + return true + }) + if (filteredMetricsList?.length === 0) return null + return { ...sm, metrics: filteredMetricsList } + }).filter(Boolean) + + if (filteredScopeMetrics?.length === 0) return null + return { ...rm, scopeMetrics: filteredScopeMetrics } + }).filter(Boolean) + + if (filteredResourceMetrics?.length === 0) return null + return { ...data, resourceMetrics: filteredResourceMetrics } + }).filter(Boolean) as MetricData[] + }, [metrics, selectedServices]) + + // --- Real Service Health & Offender Metrics --- + const serviceHealth = useMemo(() => { + const health: Record = {} + + const getOrInit = (srv: string) => { + if (!health[srv]) { + health[srv] = { + name: srv, + totalSignals: 0, + errorCount: 0, + spansCount: 0, + logsCount: 0, + hasErrors: false, + } + } + return health[srv] + } + + filteredLogs.forEach((log) => { + (log.scopeLogs ?? []).forEach((sl) => { + (sl.logRecords ?? []).forEach((r) => { + const sName = getServiceName(log.resource?.attributes ?? [], r.attributes ?? []) + if (sName && sName !== "unknown-service") { + const h = getOrInit(sName) + h.totalSignals++ + h.logsCount++ + if ((r.severityNumber ?? 0) >= 17) { + h.errorCount++ + h.hasErrors = true + } + } + }) + }) + }) + + filteredTraces.forEach((trace) => { + trace.resourceSpans?.forEach((rs) => { + rs.scopeSpans?.forEach((ss) => { + ss.spans?.forEach((s) => { + const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []) + if (sName && sName !== "unknown-service") { + const h = getOrInit(sName) + h.totalSignals++ + h.spansCount++ + if (isSpanError(s)) { + h.errorCount++ + h.hasErrors = true + } + } + }) + }) + }) + }) + + return health + }, [filteredLogs, filteredTraces]) + + + useEffect(() => { + // Do not start protected history/WebSocket traffic until a session exists. + // The auth callback reloads the app after storing tokens, so this is also + // sufficient for the normal login flow. + if (!tokens()) return + + const fetchHistory = async () => { + try { + // Fetch each signal type separately with its own generous limit, + // rather than one combined /api/history call - otherwise a noisy + // signal type (metrics in particular) can crowd the others out of + // the initial window. + const [tracesRes, logsRes, metricsRes] = await Promise.all([ + authFetch("/api/history/traces?limit=300"), + authFetch("/api/history/logs?limit=300"), + authFetch("/api/history/metrics?limit=150"), + ]) + const [tracesData, logsData, metricsData] = await Promise.all([ + tracesRes.json() as Promise, + logsRes.json() as Promise, + metricsRes.json() as Promise, + ]) + + const partitioned = partitionMessages([ + ...(tracesData ?? []), + ...(logsData ?? []), + ...(metricsData ?? []), + ]) + + console.log( + "Fetched historical data:", + partitioned.traces.length, "traces,", + partitioned.logs.length, "logs,", + partitioned.metrics.length, "metrics", + ) + + setTraces(partitioned.traces) + setMetrics(partitioned.metrics) + setLogs(partitioned.logs) + setLogsByTraceId(partitioned.logsByTraceId) + setKnownTraceIds(partitioned.traceIds) + } catch (error) { + console.error("Error fetching history:", error) + } + } + + const connect = () => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const t = tokens() + const tokenQuery = t?.accessToken ? `?access_token=${encodeURIComponent(t.accessToken)}` : "" + const wsUrl = `${protocol}//${window.location.host}/ws${tokenQuery}` + console.log(`Connecting to WebSocket at ${wsUrl}`) + + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + console.log("WebSocket connected") + setIsConnected(true) + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + + // Server issues notification broadcast + if (data?.type === "issues") { + setIssuesVersion((v) => v + 1) + return + } + + // If stream is paused, buffer the incoming signal + if (!isStreamingRef.current) { + bufferedMessagesRef.current.push(data as OtelMessage) + setBufferedCount(bufferedMessagesRef.current.length) + return + } + + const partitioned = partitionMessages([data as OtelMessage]) + + if (partitioned.traces.length) { + setTraces((prev) => [...prev, ...partitioned.traces]) + } + if (partitioned.metrics.length) { + setMetrics((prev) => [...prev, ...partitioned.metrics]) + } + if (partitioned.logs.length) { + setLogs((prev) => [...prev, ...partitioned.logs]) + setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) + } + if (partitioned.traceIds.size) { + setKnownTraceIds((prev) => { + const next = new Set(prev) + partitioned.traceIds.forEach((id) => next.add(id)) + return next + }) + } + } catch (error) { + console.error("Error parsing WebSocket message:", error) + } + } + + ws.onerror = (error) => { + console.error("WebSocket error:", error) + setIsConnected(false) + } + + ws.onclose = () => { + console.log("WebSocket disconnected, reconnecting in 5s...") + setIsConnected(false) + + // Reconnect after 5 seconds + reconnectTimeoutRef.current = setTimeout(() => { + connect() + }, 5000) + } + } + fetchHistory().then(() => connect()) + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + } + }, [flushBufferedStream]) + + // On-demand trace fetch: if the selected trace id isn't one we already + // have spans for (e.g. it arrived before this session connected, or fell + // outside the initial history window), fetch it directly instead of + // silently showing an empty trace view. + useEffect(() => { + if (!tokens() || !activeTraceId || knownTraceIds.has(activeTraceId)) return + + let cancelled = false + authFetch(`/api/traces/${encodeURIComponent(activeTraceId)}`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise + }) + .then((data) => { + if (cancelled) return + const partitioned = partitionMessages(data ?? []) + + if (partitioned.traces.length) { + setTraces((prev) => [...prev, ...partitioned.traces]) + } + if (partitioned.metrics.length) { + setMetrics((prev) => [...prev, ...partitioned.metrics]) + } + if (partitioned.logs.length) { + setLogs((prev) => [...prev, ...partitioned.logs]) + setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) + } + + setKnownTraceIds((prev) => { + const next = new Set(prev) + partitioned.traceIds.forEach((id) => next.add(id)) + next.add(activeTraceId) + return next + }) + }) + .catch((error) => { + console.error(`Error fetching trace ${activeTraceId}:`, error) + }) + + return () => { + cancelled = true + } + }, [activeTraceId, knownTraceIds]) + + return ( + + {children} + + ) + +} diff --git a/otel-ui/src/contexts/ObservabilityContextValue.ts b/otel-ui/src/contexts/ObservabilityContextValue.ts new file mode 100644 index 0000000..6a1bd1d --- /dev/null +++ b/otel-ui/src/contexts/ObservabilityContextValue.ts @@ -0,0 +1,88 @@ +"use client"; + +import { createContext } from "react"; +import type { LogEntry, MetricData, TraceData } from "../lib/types"; + +export interface FilterState { + selectedServices: Set; + excludedAttributes: Record>; +} + +export type TimeWindow = "15m" | "1h" | "6h" | "24h" | "all"; + +export interface ServiceHealthSummary { + name: string; + totalSignals: number; + errorCount: number; + spansCount: number; + logsCount: number; + hasErrors: boolean; +} + +export interface TimeRangeFilter { + startMs: number; + endMs: number; + label?: string; +} + +interface ObservabilityContextType { + logs: LogEntry[]; + metrics: MetricData[]; + traces: TraceData[]; + unfocusedLogs: LogEntry[]; + unfocusedTraces: TraceData[]; + logsByTraceId: Record; + logCountBySpanId: Record; + isConnected: boolean; + + // Real Streaming / Live Tail Controls + isStreaming: boolean; + setIsStreaming: (streaming: boolean) => void; + bufferedCount: number; + flushBufferedStream: () => void; + + // Real Time Window Filter + timeWindow: TimeWindow; + setTimeWindow: (window: TimeWindow) => void; + + // Selected Time Period Filter (from waveform or custom selection) + selectedTimeRange: TimeRangeFilter | null; + setSelectedTimeRange: (range: TimeRangeFilter | null) => void; + + // Latency / Anomaly Threshold Filter + minDurationMs: number; + setMinDurationMs: (ms: number) => void; + + // Service Health & Facets + serviceHealth: Record; + issuesVersion: number; + + // Global filters + selectedServices: Set; + toggleService: (service: string) => void; + excludedAttributes: Record>; + toggleExcludedValue: (key: string, value: string) => void; + removeExcludedValue: (key: string, value: string) => void; + clearAttributeKey: (key: string) => void; + resetFilters: () => void; + uniqueServices: string[]; + showErrorsOnly: boolean; + setShowErrorsOnly: (show: boolean) => void; + searchQuery: string; + setSearchQuery: (query: string) => void; + + // Active selection + activeTraceId: string | null; + setActiveTraceId: (id: string | null) => void; + activeSpanId: string | null; + setActiveSpanId: (id: string | null) => void; + activeTab: string; + setActiveTab: (tab: string) => void; + filtersExpanded: boolean; + setFiltersExpanded: (expanded: boolean) => void; +} + +export const ObservabilityContext = createContext< + ObservabilityContextType | undefined +>(undefined); + diff --git a/otel-ui/src/hooks/use-mobile.ts b/otel-ui/src/hooks/use-mobile.ts new file mode 100644 index 0000000..4331d5c --- /dev/null +++ b/otel-ui/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from 'react' + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} diff --git a/otel-ui/src/hooks/useIssues.ts b/otel-ui/src/hooks/useIssues.ts new file mode 100644 index 0000000..86f515e --- /dev/null +++ b/otel-ui/src/hooks/useIssues.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect } from "react" +import useSWR from "swr" +import type { Issue } from "../lib/types" +import { authSWRFetcher, tokens } from "../lib/auth" +import { useObservabilityData } from "./useObservabilityData" + + +const FALLBACK_POLL_INTERVAL_MS = 30_000 +const LIVE_REFRESH_DEBOUNCE_MS = 1_000 +let lastLiveRefreshAt = 0 + +// Polls the backend's deduplicated error view (GET /api/issues) rather than +// deriving "errors" from the raw telemetry stream in the browser, so counts, +// first/last-seen and dedup match what the server has actually persisted - +// not just whatever fits in this tab's in-memory window. Updates arrive live +// over the WebSocket (useLiveIssues); the interval is only a fallback for +// when the socket is down. +export function useIssues() { + const key = tokens() ? "/api/issues?limit=200" : null + const { data, error, isLoading, mutate } = useSWR( + key, + authSWRFetcher, + { + refreshInterval: FALLBACK_POLL_INTERVAL_MS, + revalidateOnFocus: false, + shouldRetryOnError: false, + isPaused: () => !tokens(), + }, + ) + const refresh = useCallback(() => mutate(), [mutate]) + + const { issuesVersion } = useObservabilityData() + + // Live push over the primary WebSocket + useEffect(() => { + if (issuesVersion > 0 && tokens()) { + const now = Date.now() + if (now - lastLiveRefreshAt < LIVE_REFRESH_DEBOUNCE_MS) return + lastLiveRefreshAt = now + void refresh() + } + }, [issuesVersion, refresh]) + + return { + issues: data ?? [], + loading: isLoading, + error: error instanceof Error ? error.message : error ? String(error) : null, + refresh, + } +} diff --git a/otel-ui/src/hooks/useLiveIssues.ts b/otel-ui/src/hooks/useLiveIssues.ts new file mode 100644 index 0000000..19a01fc --- /dev/null +++ b/otel-ui/src/hooks/useLiveIssues.ts @@ -0,0 +1,84 @@ +// Live issue updates: the backend broadcasts "issues" events over the same +// WebSocket as telemetry, so the Issues tab can refresh instantly when an +// error is ingested instead of waiting for the next poll tick. Falls back to +// polling if the socket misbehaves. +import { useEffect, useState } from "react" + +export function useLiveIssues(onRefresh: () => void) { + const [live, setLive] = useState(false) + + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + let ws: WebSocket | null = null + let retryTimer: number | null = null + let pollTimer: number | null = null + let disposed = false + + const startPolling = () => { + if (pollTimer === null) { + pollTimer = window.setInterval(onRefresh, 30_000) + } + } + const stopPolling = () => { + if (pollTimer !== null) { + window.clearInterval(pollTimer) + pollTimer = null + } + } + + const connect = () => { + if (disposed) return + try { + ws = new WebSocket(`${protocol}//${window.location.host}/ws`) + } catch { + startPolling() + return + } + + ws.onopen = () => { + setLive(true) + stopPolling() + } + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + if (msg?.type === "issues") onRefresh() + } catch { + /* ignore malformed frames */ + } + } + ws.onclose = () => { + setLive(false) + startPolling() + // Reuse the telemetry socket's cadence for reconnects. + retryTimer = window.setTimeout(connect, 5000) + } + ws.onerror = () => { + try { + ws?.close() + } catch { + /* ignore */ + } + } + } + + connect() + + return () => { + disposed = true + if (retryTimer !== null) window.clearTimeout(retryTimer) + stopPolling() + if (ws) { + ws.onclose = null + try { + ws.close() + } catch { + /* ignore */ + } + } + } + }, [onRefresh]) + + // Exposed so the Issues header can show a "live" vs "polling" indicator. + return live +} diff --git a/otel-ui/src/hooks/useLogsForTraceId.ts b/otel-ui/src/hooks/useLogsForTraceId.ts new file mode 100644 index 0000000..f7e793c --- /dev/null +++ b/otel-ui/src/hooks/useLogsForTraceId.ts @@ -0,0 +1,7 @@ +import { useMemo } from "react"; +import { useObservabilityData } from "./useObservabilityData"; + +export const useLogsForTraceId = (traceId: string) => { + const { logsByTraceId } = useObservabilityData(); + return useMemo(() => logsByTraceId[traceId] || [], [logsByTraceId, traceId]); +}; \ No newline at end of file diff --git a/otel-ui/src/hooks/useMetrics.ts b/otel-ui/src/hooks/useMetrics.ts new file mode 100644 index 0000000..64a43a8 --- /dev/null +++ b/otel-ui/src/hooks/useMetrics.ts @@ -0,0 +1,129 @@ +import { useMemo } from "react"; +import { useObservabilityData } from "./useObservabilityData"; +import type { MetricData, MetricDataPoint } from "../lib/types"; +import { getPodName } from "../lib/otel-utils"; + +export interface MetricDetails { + id: string; + name: string; + service: string; + type: "gauge" | "sum" | "histogram"; + points: MetricDataPoint[]; + unit?: string; + pods: string[]; +} + +export const useMetricsDetails = (): Record => { + const { metrics } = useObservabilityData(); + return useMemo(() => { + const allMetrics: Record< + string, + { + id: string; + name: string; + service: string; + type: "gauge" | "sum" | "histogram"; + points: MetricDataPoint[]; + unit?: string; + podsSet: Set; + } + > = {}; + + metrics.forEach((metricData: MetricData) => { + (metricData.resourceMetrics ?? []).forEach((resourceMetric) => { + const resourceAttrs = resourceMetric.resource?.attributes ?? []; + const serviceName = + resourceAttrs.find((attr) => attr.key === "service.name")?.value.stringValue || "unknown-service"; + const resourcePod = getPodName(resourceAttrs); + + (resourceMetric.scopeMetrics ?? []).forEach((scopeMetric) => { + (scopeMetric.metrics ?? []).forEach((metric) => { + const key = `${serviceName}:${metric.name}`; + if (!allMetrics[key]) { + let detectedType: "gauge" | "sum" | "histogram" = "sum"; + if (metric.gauge) detectedType = "gauge"; + else if (metric.histogram) detectedType = "histogram"; + + allMetrics[key] = { + id: key, + name: metric.name, + service: serviceName, + type: detectedType, + points: [], + unit: (metric as any).unit || undefined, + podsSet: new Set(), + }; + } + + const enrichPoint = (p: MetricDataPoint): MetricDataPoint => { + const pod = getPodName(undefined, p.attributes) || resourcePod; + if (pod) { + allMetrics[key].podsSet.add(pod); + } + return { + ...p, + pod: pod || undefined, + }; + }; + + if (metric.gauge) { + allMetrics[key].type = "gauge"; + allMetrics[key].points.push(...(metric.gauge.dataPoints ?? []).map(enrichPoint)); + } + if (metric.sum) { + if (allMetrics[key].type !== "gauge") { + allMetrics[key].type = "sum"; + } + allMetrics[key].points.push(...(metric.sum.dataPoints ?? []).map(enrichPoint)); + } + if (metric.histogram) { + if (allMetrics[key].type !== "gauge") { + allMetrics[key].type = "histogram"; + } + const histogramPoints = (metric.histogram.dataPoints ?? []).map((point) => + enrichPoint({ + ...point, + attributes: point.attributes ?? [], + asDouble: point.sum ?? 0, + }) + ); + allMetrics[key].points.push(...histogramPoints); + } + }); + }); + }); + }); + + const result: Record = {}; + for (const key in allMetrics) { + const sorted = allMetrics[key].points.sort((a, b) => { + const aT = BigInt(a.timeUnixNano || "0"); + const bT = BigInt(b.timeUnixNano || "0"); + if (aT < bT) return -1; + if (aT > bT) return 1; + return 0; + }); + result[key] = { + id: key, + name: allMetrics[key].name, + service: allMetrics[key].service, + type: allMetrics[key].type, + points: sorted.slice(-600), + unit: allMetrics[key].unit, + pods: Array.from(allMetrics[key].podsSet).sort(), + }; + } + return result; + }, [metrics]); +}; + +export const useMetrics = () => { + const details = useMetricsDetails(); + return useMemo(() => { + const grouped: Record = {}; + for (const key in details) { + grouped[key] = details[key].points; + } + return grouped; + }, [details]); +}; diff --git a/otel-ui/src/hooks/useObservabilityData.ts b/otel-ui/src/hooks/useObservabilityData.ts new file mode 100644 index 0000000..b5a081b --- /dev/null +++ b/otel-ui/src/hooks/useObservabilityData.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useContext } from "react"; +import { ObservabilityContext } from "../contexts/ObservabilityContextValue"; + +export function useObservabilityData() { + const context = useContext(ObservabilityContext); + if (!context) { + throw new Error( + "useObservabilityData must be used within an ObservabilityProvider", + ); + } + return context; +} diff --git a/otel-ui/src/hooks/usePatterns.ts b/otel-ui/src/hooks/usePatterns.ts new file mode 100644 index 0000000..723c367 --- /dev/null +++ b/otel-ui/src/hooks/usePatterns.ts @@ -0,0 +1,41 @@ +import useSWR from "swr" +import { authSWRFetcher, authFetch, tokens } from "../lib/auth" +import type { Issue, IssuePattern, SimilarIssue } from "../lib/types" + +export function usePatterns() { + const enabled = Boolean(tokens()) + const patterns = useSWR(enabled ? "/api/patterns" : null, authSWRFetcher, { + revalidateOnFocus: false, + refreshInterval: 30_000, + }) + const issues = useSWR(enabled ? "/api/issues?limit=200" : null, authSWRFetcher, { + revalidateOnFocus: false, + refreshInterval: 30_000, + }) + + const similar = async (id: string) => { + const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar?limit=8`) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return (await response.json()) as SimilarIssue[] + } + + const review = async (id: string, otherId: string, decision: "same" | "different") => { + const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ issueId: otherId, decision }), + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + await Promise.all([patterns.mutate(), issues.mutate()]) + } + + return { + patterns: patterns.data ?? [], + issues: issues.data ?? [], + loading: patterns.isLoading || issues.isLoading, + error: patterns.error ?? issues.error, + refresh: () => Promise.all([patterns.mutate(), issues.mutate()]), + similar, + review, + } +} diff --git a/otel-ui/src/hooks/useRollouts.ts b/otel-ui/src/hooks/useRollouts.ts new file mode 100644 index 0000000..f7abc95 --- /dev/null +++ b/otel-ui/src/hooks/useRollouts.ts @@ -0,0 +1,14 @@ +import useSWR from "swr" +import { authSWRFetcher, authFetch, tokens } from "../lib/auth" +import type { Rollout, RolloutImpact } from "../lib/types" + +export function useRollouts() { + const key = tokens() ? "/api/rollouts" : null + const state = useSWR(key, authSWRFetcher, { refreshInterval: 30_000, revalidateOnFocus: false, shouldRetryOnError: false }) + const impact = async (id: string) => { + const response = await authFetch(`/api/rollouts/${encodeURIComponent(id)}/impact`) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return (await response.json()) as RolloutImpact + } + return { rollouts: state.data ?? [], loading: state.isLoading, error: state.error, refresh: state.mutate, impact } +} diff --git a/otel-ui/src/hooks/useStats.ts b/otel-ui/src/hooks/useStats.ts new file mode 100644 index 0000000..5078393 --- /dev/null +++ b/otel-ui/src/hooks/useStats.ts @@ -0,0 +1,60 @@ +import { useCallback } from "react" +import useSWR from "swr" +import type { ErrorStatsResponse, ReleaseStatsResponse } from "../lib/types" +import { authFetch, authSWRFetcher, tokens } from "../lib/auth" + +/** + * True when the backend has ClickHouse-backed stats (the endpoints answer + * 200 instead of the documented 501). null while checking, so the Trends + * tab can hide itself entirely on file-store deployments. + */ +export function useStatsAvailability() { + const { data } = useSWR(tokens() ? "/api/stats/services" : null, async (key) => { + const res = await authFetch(key) + return res.ok + }, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() }) + return data ?? null +} + +export interface StatsRange { + /** Lookback window in hours; server caps at 2160 (90 days). */ + hours: number + service: string +} + +const DEFAULT_RANGE: StatsRange = { hours: 72, service: "" } + +/** + * Fetches both error-trend and release-comparison series for the selected + * range. Both endpoints come from the same ClickHouse rollup, so a single + * refresh covers the tab. + */ +export function useStats(range: StatsRange = DEFAULT_RANGE) { + const params = new URLSearchParams({ hours: String(range.hours) }) + if (range.service) params.set("service", range.service) + const key = tokens() ? `/api/stats/errors?${params}` : null + const { data, error, isLoading, mutate } = useSWR<{ + errors: ErrorStatsResponse + releases: ReleaseStatsResponse | null + services: string[] + }>(key, async () => { + const [errors, releases, services] = await Promise.all([ + authSWRFetcher(`/api/stats/errors?${params}`), + authSWRFetcher(`/api/stats/releases?${params}`).catch(() => null), + authSWRFetcher<{ services?: string[] }>("/api/stats/services").catch(() => null), + ]) + return { errors, releases, services: services?.services ?? [] } + }, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() }) + + const refresh = useCallback(() => mutate(), [mutate]) + + return { + errorPoints: data?.errors.points ?? [], + granularity: data?.errors.granularity ?? "hour", + releaseSeries: data?.releases?.series ?? {}, + services: data?.services ?? [], + loading: isLoading, + error: error instanceof Error ? error.message : error ? String(error) : null, + refresh, + } +} diff --git a/otel-ui/src/index.css b/otel-ui/src/index.css new file mode 100644 index 0000000..559d937 --- /dev/null +++ b/otel-ui/src/index.css @@ -0,0 +1,90 @@ +@import "tailwindcss"; + +@theme { + --color-background: #0f131c; + --color-surface: #0f131c; + --color-surface-dim: #0f131c; + --color-surface-bright: #353942; + --color-surface-container-lowest: #0a0e16; + --color-surface-container-low: #181c24; + --color-surface-container: #1c2028; + --color-surface-container-high: #262a33; + --color-surface-container-highest: #31353e; + --color-surface-variant: #31353e; + --color-surface-tint: #00dbe9; + + --color-on-background: #dfe2ee; + --color-on-surface: #dfe2ee; + --color-on-surface-variant: #b9cacb; + --color-inverse-surface: #dfe2ee; + --color-inverse-on-surface: #2c3039; + + --color-primary: #dbfcff; + --color-primary-container: #00f0ff; + --color-on-primary: #00363a; + --color-on-primary-container: #006970; + --color-primary-fixed: #7df4ff; + --color-primary-fixed-dim: #00dbe9; + --color-on-primary-fixed: #002022; + --color-on-primary-fixed-variant: #004f54; + --color-inverse-primary: #006970; + + --color-secondary: #4edea3; + --color-secondary-container: #00a572; + --color-on-secondary: #003824; + --color-on-secondary-container: #00311f; + --color-secondary-fixed: #6ffbbe; + --color-secondary-fixed-dim: #4edea3; + --color-on-secondary-fixed: #002113; + --color-on-secondary-fixed-variant: #005236; + + --color-tertiary: #fff3f3; + --color-tertiary-container: #ffcdd1; + --color-on-tertiary: #670020; + --color-on-tertiary-container: #be0042; + --color-tertiary-fixed: #ffd9dc; + --color-tertiary-fixed-dim: #ffb2ba; + --color-on-tertiary-fixed: #400011; + --color-on-tertiary-fixed-variant: #910030; + + --color-error: #ffb4ab; + --color-error-container: #93000a; + --color-on-error: #690005; + --color-on-error-container: #ffdad6; + + --color-outline: #849495; + --color-outline-variant: #3b494b; + + --font-headline: "Geist", sans-serif; + --font-body: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +@layer base { + html, body { + margin: 0; + padding: 0; + background-color: #0f131c; + color: #dfe2ee; + font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + } + body { + overscroll-behavior: none; + } +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 20px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; +} diff --git a/otel-ui/src/lib/auth.ts b/otel-ui/src/lib/auth.ts new file mode 100644 index 0000000..81770d6 --- /dev/null +++ b/otel-ui/src/lib/auth.ts @@ -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 | null = null; + +// ensureTokens() returns valid tokens, refreshing if close to expiration. +export async function ensureTokens(): Promise { + 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 { + 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 { + 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 { + 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(key: string): Promise { + const response = await authFetch(key) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.json() as Promise +} diff --git a/otel-ui/src/lib/ingest.ts b/otel-ui/src/lib/ingest.ts new file mode 100644 index 0000000..6d3a2d2 --- /dev/null +++ b/otel-ui/src/lib/ingest.ts @@ -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 + traceIds: Set +} + +export function partitionMessages(messages: OtelMessage[]): PartitionedMessages { + const traces: TraceData[] = [] + const metrics: MetricData[] = [] + const logs: LogEntry[] = [] + const logsByTraceId: Record = {} + const traceIds = new Set() + + 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, + additions: Record, +): Record { + if (Object.keys(additions).length === 0) return prev + const next = { ...prev } + Object.entries(additions).forEach(([traceId, entries]) => { + next[traceId] = [...(next[traceId] ?? []), ...entries] + }) + return next +} diff --git a/otel-ui/src/lib/issue-utils.ts b/otel-ui/src/lib/issue-utils.ts new file mode 100644 index 0000000..aed225f --- /dev/null +++ b/otel-ui/src/lib/issue-utils.ts @@ -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 = { + 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 +} diff --git a/otel-ui/src/lib/normalize.ts b/otel-ui/src/lib/normalize.ts new file mode 100644 index 0000000..9189735 --- /dev/null +++ b/otel-ui/src/lib/normalize.ts @@ -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, + })), + })), + })), +}) diff --git a/otel-ui/src/lib/otel-utils.ts b/otel-ui/src/lib/otel-utils.ts new file mode 100644 index 0000000..fb1e1e5 --- /dev/null +++ b/otel-ui/src/lib/otel-utils.ts @@ -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; +}; diff --git a/otel-ui/src/lib/routes.ts b/otel-ui/src/lib/routes.ts new file mode 100644 index 0000000..ccaa917 --- /dev/null +++ b/otel-ui/src/lib/routes.ts @@ -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 = { + "/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 +} diff --git a/otel-ui/src/lib/timeUtils.ts b/otel-ui/src/lib/timeUtils.ts new file mode 100644 index 0000000..51deee3 --- /dev/null +++ b/otel-ui/src/lib/timeUtils.ts @@ -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`; +} + diff --git a/otel-ui/src/lib/types.ts b/otel-ui/src/lib/types.ts new file mode 100644 index 0000000..dc42c92 --- /dev/null +++ b/otel-ui/src/lib/types.ts @@ -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; + 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; + 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; +} diff --git a/otel-ui/src/main.tsx b/otel-ui/src/main.tsx new file mode 100644 index 0000000..4f7310c --- /dev/null +++ b/otel-ui/src/main.tsx @@ -0,0 +1,31 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Router } from "wouter"; +import "./index.css"; +import App from "./App.tsx"; +import { ObservabilityProvider } from "./contexts/ObservabilityContextProvider.tsx"; +import { ViewErrorBoundary } from "./components/ViewErrorBoundary"; + +// Use the project's own browser telemetry for UI errors, failed requests and +// Web Vitals. The script is loaded before this module in index.html. +const rum = (window as Window & { + OtelRUM?: { init: (config: Record) => unknown } +}).OtelRUM; +rum?.init({ + serviceName: "otel-ui", + release: (import.meta.env.VITE_APP_RELEASE as string | undefined) ?? "dev", + baseUrl: window.location.origin, + endpoint: "/v1/logs", +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + + + , +); diff --git a/otel-ui/src/styles/globals.css b/otel-ui/src/styles/globals.css new file mode 100644 index 0000000..dc2aea1 --- /dev/null +++ b/otel-ui/src/styles/globals.css @@ -0,0 +1,125 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/otel-ui/tsconfig.app.json b/otel-ui/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/otel-ui/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/otel-ui/tsconfig.json b/otel-ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/otel-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/otel-ui/tsconfig.node.json b/otel-ui/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/otel-ui/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/otel-ui/vite.config.ts b/otel-ui/vite.config.ts new file mode 100644 index 0000000..e21d795 --- /dev/null +++ b/otel-ui/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + // Upload these maps to the receiver during deployment; do not publish + // them with the static UI. + sourcemap: true, + }, + server: { + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + }, + "/ws": { + target: "ws://localhost:8080", + ws: true, + }, + }, + }, +}); diff --git a/scripts/upload-sourcemap.sh b/scripts/upload-sourcemap.sh new file mode 100755 index 0000000..41ffe4c --- /dev/null +++ b/scripts/upload-sourcemap.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${OTEL_URL:?set OTEL_URL, for example https://otel.example.com}" +: "${RELEASE:?set RELEASE to the exact VITE_APP_RELEASE used at build time}" + +bundle="${1:?usage: RELEASE=... OTEL_URL=... $0 dist/assets/index-HASH.js}" +map="${bundle}.map" +test -f "$bundle" || { echo "bundle not found: $bundle" >&2; exit 1; } +test -f "$map" || { echo "source map not found: $map" >&2; exit 1; } + +source_path="/${bundle#*/dist/}" +curl --fail-with-body --silent --show-error \ + -X POST "${OTEL_URL%/}/api/sourcemaps" \ + -F "release=${RELEASE}" \ + -F "source=${source_path}" \ + -F "file=@${map};type=application/json" +echo "uploaded ${source_path} for ${RELEASE}" diff --git a/static/otel-rum.js b/static/otel-rum.js index d2aebaf..547cceb 100644 --- a/static/otel-rum.js +++ b/static/otel-rum.js @@ -1,10 +1,11 @@ /*! - * otel-rum.js - minimal browser error/RUM collector for go-otel. + * otel-rum.js - browser error/RUM collector for go-otel. * - * Captures uncaught errors, unhandled promise rejections and failed - * resource loads, and ships them as OTLP log records to this server's - * existing /v1/logs endpoint (no separate ingestion path needed - the - * backend's issue view picks these up like any other error log). + * Captures uncaught errors, unhandled promise rejections, failed resource + * loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and + * ships them as OTLP log records to this server's existing /v1/logs endpoint + * (no separate ingestion path needed - the backend's issue view picks these + * up like any other error log). * * Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can * carry a W3C `traceparent` header. If the backend service is itself @@ -16,7 +17,10 @@ * Usage: * * * - * Explicitly out of scope for v1: source map resolution (stack traces stay - * minified), session replay, PII scrubbing. Don't route sensitive data - * through breadcrumbs/attributes. + * Events are batched client-side (flushed on pagehide via keepalive fetch/sendBeacon) and + * rate-limited, so a runaway error loop cannot hammer the collector. + * + * Source-map resolution is performed server-side by go-otel when a matching + * release map has been uploaded. Session replay and PII scrubbing remain out + * of scope; don't route sensitive data through breadcrumbs/attributes. */ (function (global) { 'use strict'; + var DEFAULT_BASE_URL = 'https://otel.k6n.net'; + + // Find the loading script so declarative data-* configuration keeps working. + try { + var scriptEl = (typeof document !== 'undefined') ? (document.currentScript || (function() { + var scripts = document.getElementsByTagName('script'); + for (var i = scripts.length - 1; i >= 0; i--) { + if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i]; + } + return null; + })()) : null; + } catch (e) {} + + var rawFetch = typeof global.fetch === 'function' ? global.fetch : null; + var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null; + function randomHex(len) { var chars = '0123456789abcdef'; var out = ''; @@ -73,17 +96,51 @@ function init(config) { config = config || {}; - var endpoint = config.endpoint || '/v1/logs'; - var serviceName = config.serviceName || 'frontend'; - var environment = config.environment || ''; - var release = config.release || ''; - var ingestKey = config.ingestKey || ''; + var scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || ''; + var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || ''; + var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || ''; + var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || ''; + + // Collector base URL resolution: + // 1. Explicit config.baseUrl or scriptEl data-base-url + // 2. Production collector: https://otel.k6n.net + // Do not derive this from the page or script origin: the script may be + // bundled or self-hosted while logs still belong at the collector. + var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); + var endpoint = config.endpoint || scriptEndpoint; + if (!endpoint) { + endpoint = baseUrl + '/v1/logs'; + } else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) { + endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, ''); + } + + function isCollectorUrl(u) { + if (!u) return false; + var s = String(u); + if (endpoint && s.indexOf(endpoint) === 0) return true; + if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1) return true; + return false; + } + + var serviceName = config.serviceName || scriptServiceName || 'frontend'; + var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || ''; + var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || ''; + var ingestKey = config.ingestKey || scriptIngestKey || ''; var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1; var maxBreadcrumbs = config.maxBreadcrumbs || 20; var captureConsoleErrors = config.captureConsoleErrors !== false; var autoInstrumentFetch = config.autoInstrumentFetch !== false; + var autoInstrumentXhr = config.autoInstrumentXhr !== false; + var trackSpaRoutes = config.trackSpaRoutes !== false; + var reportHttpFailures = config.reportHttpFailures !== false; + var captureWebVitals = config.captureWebVitals !== false; var injectTraceHeaders = config.injectTraceHeaders !== false; var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only + // Client-side rate limiting: max events per rolling minute. + var maxEventsPerMinute = config.maxEventsPerMinute || 60; + // Batch size / flush interval for queued records. + var batchSize = config.batchSize || 10; + var flushIntervalMs = config.flushIntervalMs || 5000; var breadcrumbs = []; // Best-effort correlation: the most recently generated trace id is @@ -92,37 +149,128 @@ // not a real per-async-context propagation mechanism. var lastTraceId = ''; - function addBreadcrumb(category, message) { - breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) }); - if (breadcrumbs.length > maxBreadcrumbs) { - breadcrumbs.shift(); + // Session id: survives reloads within the tab's lifetime, so server + //-side issue views can group frontend errors by browsing session. + var sessionId = ''; + try { + sessionId = global.sessionStorage.getItem('otel-rum-session'); + if (!sessionId) { + sessionId = randomHex(16); + global.sessionStorage.setItem('otel-rum-session', sessionId); + } + } catch (e) { + sessionId = randomHex(16); // storage blocked (privacy mode etc.) + } + + // --- Client-side queue, batching and rate limiting ----------------- + + var queue = []; + var flushTimer = null; + var sentTimestamps = []; + var eventsDropped = 0; + + function underRateLimit() { + var now = Date.now(); + // Drop timestamps older than the rolling 60s window. + while (sentTimestamps.length && now - sentTimestamps[0] > 60000) { + sentTimestamps.shift(); + } + if (sentTimestamps.length >= maxEventsPerMinute) return false; + sentTimestamps.push(now); + return true; + } + + function enqueue(record) { + queue.push(record); + if (queue.length >= batchSize) { + flush(); + return; + } + if (!flushTimer) { + flushTimer = global.setTimeout(flush, flushIntervalMs); } } - // traceHeaders returns a headers object carrying a fresh (or - // continued) W3C traceparent, ready to spread into any HTTP client's - // request headers - fetch, XHR, axios, etc. - function traceHeaders(existingTraceId) { - var traceId = existingTraceId || newTraceId(); - var spanId = newSpanId(); - lastTraceId = traceId; - return { traceparent: '00-' + traceId + '-' + spanId + '-01' }; + function flush() { + if (flushTimer) { + global.clearTimeout(flushTimer); + flushTimer = null; + } + if (!queue.length) return; + var records = queue.splice(0, queue.length); + ship(records); } - function send(severity, message, attrs, traceId) { - if (sampleRate < 1 && Math.random() > sampleRate) return; + // ship sends already-built log records in one OTLP payload. Uses + // sendBeacon when available (survives page unload), else keepalive + // fetch. + function ship(records) { + var payload = { + resourceLogs: [ + { + resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) }, + scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }] + } + ] + }; + var body = JSON.stringify(payload); + var url = endpoint; + if (ingestKey) { + url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); + } + + // Use uninstrumented rawFetch with keepalive: true and mode: 'cors' + // to avoid intercepting and looping on our own telemetry requests. + var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null); + if (fetchFn) { + var fetchHeaders = { 'Content-Type': 'application/json' }; + if (ingestKey) { + fetchHeaders['X-Otel-Ingest-Key'] = ingestKey; + } + fetchFn.call(global, url, { + method: 'POST', + mode: 'cors', + headers: fetchHeaders, + body: body, + keepalive: true + }).catch(function () { + if (global.navigator && typeof global.navigator.sendBeacon === 'function') { + try { + var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' }); + global.navigator.sendBeacon(url, blob); + } catch (e) {} + } + }); + return; + } + + if (global.navigator && typeof global.navigator.sendBeacon === 'function') { + try { + var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' }); + global.navigator.sendBeacon(url, blob); + } catch (e) {} + } + } + + var severityNumbers = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 }; + + function buildRecord(severity, message, attrs, traceId) { var record = { timeUnixNano: String(Date.now() * 1e6), severityText: severity, - severityNumber: severity === 'FATAL' ? 21 : 17, + severityNumber: severityNumbers[severity] || 17, body: { stringValue: String(message).slice(0, 2000) }, attributes: toOtlpAttributes( Object.assign( + {}, + contextAttrs, { source: 'frontend', 'browser.url': global.location ? global.location.href : '', 'browser.userAgent': global.navigator ? global.navigator.userAgent : '', + 'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '', + 'session.id': sessionId, 'service.release': release, 'service.environment': environment, breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined @@ -134,33 +282,114 @@ if (traceId) { record.traceId = traceId; } + return record; + } - var payload = { - resourceLogs: [ - { - resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) }, - scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: [record] }] - } - ] - }; - - var body = JSON.stringify(payload); - var url = endpoint; - if (ingestKey) { - url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); + function send(severity, message, attrs, traceId) { + if (sampleRate < 1 && Math.random() > sampleRate) return; + if (!underRateLimit()) { + eventsDropped++; + return; } + enqueue(buildRecord(severity, message, attrs, traceId)); + } + + // Web Vitals via PerformanceObserver. Values are reported as INFO logs + // tagged rum.vital=; the server can chart/aggregate them like + // any other telemetry. Each vital fires once when it reaches its + // final value (plus a first-input proxy for INP on older browsers). + function observeVitals() { + if (typeof global.PerformanceObserver !== 'function') return; + + function report(name, value, extra) { + var attrs = Object.assign({ 'rum.vital': name, 'rum.value': Math.round(value) }, extra || {}); + // Vitals are telemetry, not errors: bypass the error rate limit + // but keep sampling. + if (sampleRate < 1 && Math.random() > sampleRate) return; + enqueue(buildRecord('INFO', name + ' ' + Math.round(value), attrs, '')); + } + + function onPaint(list) { + list.getEntries().forEach(function (entry) { + if (entry.name === 'first-contentful-paint') report('FCP', entry.startTime); + }); + } + + function onLCP(list) { + var entries = list.getEntries(); + var last = entries[entries.length - 1]; + if (last) report('LCP', last.startTime, { 'rum.element': last.element && last.element.tagName }); + } + + function onCLS() { + var cls = 0; + var lastReport = 0; + var observer = new global.PerformanceObserver(function (list) { + list.getEntries().forEach(function (entry) { + if (!entry.hadRecentInput) cls += entry.value; + }); + // Throttle: report at most once per 10s, plus a final value on + // pagehide, instead of once per layout shift. + var now = Date.now(); + if (now - lastReport > 10000) { + lastReport = now; + report('CLS', Math.round(cls * 1000) / 1000); + } + clsFinal = function () { + report('CLS', Math.round(cls * 1000) / 1000); + clsFinal = null; + }; + }); + try { + observer.observe({ type: 'layout-shift', buffered: true }); + } catch (e) { + /* unsupported */ + clsFinal = null; + } + } + + function onINP(entry) { + var duration = entry.duration; + var interactionId = entry.interactionId; + if (!interactionId) return; + if (duration > (observerState.maxINP || 0)) { + observerState.maxINP = duration; + report('INP', duration); + } + } + + var observerState = {}; try { - if (global.navigator && typeof global.navigator.sendBeacon === 'function') { - var blob = new global.Blob([body], { type: 'application/json' }); - if (global.navigator.sendBeacon(url, blob)) return; - } - } catch (e) { - // fall through to fetch - } - if (typeof global.fetch === 'function') { - global.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(function () {}); - } + new global.PerformanceObserver(onPaint).observe({ type: 'paint', buffered: true }); + } catch (e) {} + try { + new global.PerformanceObserver(onLCP).observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (e) {} + onCLS(); + try { + new global.PerformanceObserver(function (list) { + list.getEntries().forEach(onINP); + }).observe({ type: 'event', buffered: true, durationThreshold: 40 }); + } catch (e) {} + + // Navigation timing: TTFB. + try { + var nav = performance.getEntriesByType('navigation')[0]; + if (nav && nav.responseStart) { + report('TTFB', nav.responseStart); + } + } catch (e) {} + } + + // traceHeaders returns a headers object carrying a fresh (or + // continued) W3C traceparent, ready to spread into any HTTP client's + // request headers - fetch, XHR, axios, etc. + function traceHeaders(existingTraceId) { + var traceId = existingTraceId || newTraceId(); + var spanId = newSpanId(); + lastTraceId = traceId; + return { traceparent: '00-' + traceId + '-' + spanId + '-01' }; } function captureException(err, extra) { @@ -184,6 +413,16 @@ send(severity || 'ERROR', message, extra, lastTraceId); } + function setContext(context) { + // Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to + // every subsequent event. Values become OTLP attributes prefixed + // with "context.". + contextAttrs = Object.assign({}, contextAttrs, context || {}); + flush(); // ensure earlier queued events don't mix contexts ambiguously + } + var contextAttrs = {}; + var clsFinal = null; + function onError(event) { // Resource load errors (img/script/link) surface as an ErrorEvent-less // Event targeting the failed element; script errors carry a message. @@ -233,45 +472,214 @@ }; } + function shouldInject(url) { + if (!injectTraceHeaders) return false; + if (!traceHeaderOrigins) return isSameOrigin(url); + return traceHeaderOrigins.some(function (o) { + return url.indexOf(o) === 0; + }); + } + + // httpFailureReport reports a failed HTTP round-trip as an ERROR event, + // so backend 5xx/4xx responses show up in the Issues view even when + // the app code swallows them. + function httpFailureReport(kind, method, url, status, traceId, extra) { + // Issue refresh failures must not create new issues notifications: + // otherwise an expired session can form an API-error feedback loop. + try { + if (new global.URL(url, global.location.href).pathname === '/api/issues') return; + } catch (e) {} + var attrs = Object.assign( + { + 'exception.type': 'HttpFailure', + 'http.request.method': method, + 'url.full': String(url).slice(0, 500), + 'http.response.status_code': status === undefined || status === 0 ? '' : String(status) + }, + extra || {} + ); + captureMessage( + method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status), + 'ERROR', + attrs + ); + } + if (autoInstrumentFetch && typeof global.fetch === 'function') { var originalFetch = global.fetch; global.fetch = function (input, requestInit) { var url = typeof input === 'string' ? input : (input && input.url) || ''; + if (isCollectorUrl(url)) { + return originalFetch.call(global, input, requestInit); + } var method = (requestInit && requestInit.method) || (input && input.method) || 'GET'; + var requestTraceId = ''; - if (injectTraceHeaders) { - var allowed = traceHeaderOrigins ? traceHeaderOrigins.some(function (o) { return url.indexOf(o) === 0; }) : isSameOrigin(url); - if (allowed) { - requestInit = requestInit || {}; - var headers = new global.Headers(requestInit.headers || (input && input.headers) || {}); - var th = traceHeaders(); - headers.set('traceparent', th.traceparent); - requestInit = Object.assign({}, requestInit, { headers: headers }); - } + if (shouldInject(url)) { + requestInit = requestInit || {}; + var headers = new global.Headers(requestInit.headers || (input && input.headers) || {}); + var th = traceHeaders(); + headers.set('traceparent', th.traceparent); + requestTraceId = th.traceparent.slice(3, 35); // trace id portion + requestInit = Object.assign({}, requestInit, { headers: headers }); } return originalFetch.call(global, input, requestInit).then( function (res) { addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status); + if (reportHttpFailures && res.status >= 400) { + httpFailureReport('fetch', method, url, res.status, requestTraceId); + } return res; }, function (err) { addBreadcrumb('fetch', method + ' ' + url + ' -> error'); + if (reportHttpFailures) { + httpFailureReport('fetch', method, url, 0, requestTraceId); + } throw err; } ); }; } - return { + // XHR instrumentation: trace-header injection + breadcrumbs + failure + // reporting, so legacy code paths (axios older versions, jQuery, etc.) + // get the same visibility as fetch. + if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') { + var originalOpen = global.XMLHttpRequest.prototype.open; + var originalSend = global.XMLHttpRequest.prototype.send; + var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader; + + global.XMLHttpRequest.prototype.open = function (method, url) { + if (isCollectorUrl(url)) { + return originalOpen.apply(this, arguments); + } + try { + this.__rum = { method: String(method), url: String(url) }; + if (shouldInject(String(url))) { + var th = traceHeaders(); + this.__rum.traceId = th.traceparent.slice(3, 35); + originalSetRequestHeader.call(this, 'traceparent', th.traceparent); + } + } catch (e) { + /* ignore */ + } + return originalOpen.apply(this, arguments); + }; + + global.XMLHttpRequest.prototype.send = function () { + var xhr = this; + var info = xhr.__rum || {}; + if (info.url) { + var origHandler = xhr.onreadystatechange; + xhr.onreadystatechange = function () { + try { + if (xhr.readyState === 4) { + addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status); + if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) { + httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId); + } + } + } catch (e) { + /* ignore */ + } + if (origHandler) return origHandler.apply(this, arguments); + }; + } + return originalSend.apply(this, arguments); + }; + } + + // SPA route-change breadcrumbs: history.pushState/replaceState and + // popstate/hashes get recorded so an error report shows which route + // transitions preceded it. + if (trackSpaRoutes && global.history) { + function recordRoute(kind) { + addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : '')); + } + var originalPush = global.history.pushState; + var originalReplace = global.history.replaceState; + if (typeof originalPush === 'function') { + global.history.pushState = function () { + var result = originalPush.apply(this, arguments); + try { + recordRoute('pushState'); + } catch (e) {} + return result; + }; + } + if (typeof originalReplace === 'function') { + global.history.replaceState = function () { + var result = originalReplace.apply(this, arguments); + try { + recordRoute('replaceState'); + } catch (e) {} + return result; + }; + } + global.addEventListener('popstate', function () { + try { + recordRoute('popstate'); + } catch (e) {} + }); + } + + // Flush pending events when the page is being hidden or unloaded so + // errors that happen right before navigation are not lost, and emit + // the final CLS value. + global.addEventListener('pagehide', function () { + if (clsFinal) clsFinal(); + flush(); + }); + global.addEventListener('visibilitychange', function () { + if (global.document && global.document.visibilityState === 'hidden') { + if (clsFinal) clsFinal(); + flush(); + } + }); + + // First paint: report vitals after observers have a chance to buffer. + if (captureWebVitals) { + observeVitals(); + } + + function addBreadcrumb(category, message) { + breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) }); + if (breadcrumbs.length > maxBreadcrumbs) { + breadcrumbs.shift(); + } + } + + var instance = { captureException: captureException, captureMessage: captureMessage, addBreadcrumb: addBreadcrumb, + setContext: setContext, + flush: flush, newTraceId: newTraceId, newSpanId: newSpanId, traceHeaders: traceHeaders }; + + // Convenience handle for manual/console verification: + // OtelRUM.last.captureMessage('test from console'). + global.OtelRUM.last = instance; + return instance; } - global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId }; + global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined }; + + // Auto-initialize if data attributes are declared on the