From adb5c1a43983747bedb119728f85e0bd787fc2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domagoj=20Andri=C4=87?= Date: Fri, 20 Mar 2026 15:13:33 +0100 Subject: [PATCH] Add initial work from Codex --- .gitignore | 9 + LICENSE | 26 +- README.md | 144 +- backend/.env.example | 59 + backend/app/__init__.py | 1 + backend/app/core/config.py | 135 + backend/app/core/otel.py | 103 + backend/app/core/security.py | 231 ++ backend/app/db/__init__.py | 1 + backend/app/db/engine.py | 34 + backend/app/db/postgres.py | 27 + backend/app/db/postgres_models.py | 86 + backend/app/db/queries.py | 167 + backend/app/services/__init__.py | 1 + backend/app/services/analytics_service.py | 373 +++ backend/app/services/persistence_service.py | 281 ++ backend/app/services/warehouse_service.py | 101 + backend/microservices/__init__.py | 1 + backend/microservices/analytics/__init__.py | 1 + backend/microservices/analytics/main.py | 260 ++ backend/microservices/api_gateway/__init__.py | 1 + backend/microservices/api_gateway/main.py | 326 ++ backend/microservices/bi_query/__init__.py | 1 + backend/microservices/bi_query/main.py | 85 + backend/microservices/common/__init__.py | 1 + backend/microservices/common/http.py | 19 + backend/microservices/persistence/__init__.py | 1 + backend/microservices/persistence/main.py | 176 ++ backend/pyproject.toml | 46 + backend/tests/test_analytics_service.py | 79 + backend/tests/test_security_tokens.py | 65 + frontend/.env.example | 13 + frontend/index.html | 12 + frontend/package-lock.json | 2749 +++++++++++++++++ frontend/package.json | 40 + frontend/src/App.tsx | 363 +++ frontend/src/api/client.ts | 53 + frontend/src/api/types.ts | 52 + frontend/src/auth/AuthContext.tsx | 90 + frontend/src/auth/oidc.ts | 105 + frontend/src/main.tsx | 31 + frontend/src/styles.css | 325 ++ frontend/src/telemetry.ts | 77 + frontend/tsconfig.app.json | 15 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 10 + k8s/microservices.yaml | 277 ++ 48 files changed, 7054 insertions(+), 16 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/otel.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/engine.py create mode 100644 backend/app/db/postgres.py create mode 100644 backend/app/db/postgres_models.py create mode 100644 backend/app/db/queries.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/analytics_service.py create mode 100644 backend/app/services/persistence_service.py create mode 100644 backend/app/services/warehouse_service.py create mode 100644 backend/microservices/__init__.py create mode 100644 backend/microservices/analytics/__init__.py create mode 100644 backend/microservices/analytics/main.py create mode 100644 backend/microservices/api_gateway/__init__.py create mode 100644 backend/microservices/api_gateway/main.py create mode 100644 backend/microservices/bi_query/__init__.py create mode 100644 backend/microservices/bi_query/main.py create mode 100644 backend/microservices/common/__init__.py create mode 100644 backend/microservices/common/http.py create mode 100644 backend/microservices/persistence/__init__.py create mode 100644 backend/microservices/persistence/main.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/test_analytics_service.py create mode 100644 backend/tests/test_security_tokens.py create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/auth/AuthContext.tsx create mode 100644 frontend/src/auth/oidc.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/telemetry.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 k8s/microservices.yaml diff --git a/.gitignore b/.gitignore index 31137a9..8e3c258 100644 --- a/.gitignore +++ b/.gitignore @@ -219,6 +219,12 @@ build/Release node_modules/ jspm_packages/ +# Frontend build/typecheck artifacts +frontend/dist/ +frontend/*.tsbuildinfo +frontend/vite.config.js +frontend/vite.config.d.ts + # Snowpack dependency directory (https://snowpack.dev/) web_modules/ @@ -312,3 +318,6 @@ dist .yarn/install-state.gz .pnp.* +# This project uses Kubernetes + Grafana Alloy instead of local docker compose collector. +docker-compose.yml +otel-collector-config.yaml diff --git a/LICENSE b/LICENSE index de58bc5..3b3abd0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,14 @@ -MIT License +Copyright (C) 2026 Domagoj Andrić -Copyright (c) 2026 domagoj +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/README.md b/README.md index d90400e..af47d57 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,144 @@ -# zavrsni-rad-otel-app +# OTel BI Forecast App +OpenTelemetry-instrumented BI platform with microservices, frontend OIDC login plus backend token validation, read-only MSSQL data warehouse access, and PostgreSQL persistence for writable app data. + +## Architecture + +- Frontend: React + TypeScript (`frontend/`) +- Backend microservices (`backend/microservices/`): + - `api_gateway`: public API, frontend JWT validation, internal token minting, routing/audit forwarding + - `bi_query`: read-only MSSQL warehouse queries + - `analytics`: forecasting, rankings, recommendations + - `persistence`: PostgreSQL writes/reads for app data +- Data sources: + - MSSQL (`WorldWideImporters`, `AdventureWorks2022DWH`) read-only only + - PostgreSQL writable app store (`audit_logs`, `forecast_runs`, `ranking_runs`, `recommendation_runs`) +- Observability: OTLP/HTTP to Grafana Alloy (`/v1/traces`, `/v1/metrics`) + +## Authentication Model + +- Frontend uses OIDC Authorization Code + PKCE. +- `api_gateway` validates frontend bearer JWT (`iss`, `aud`, signature, expiry, optional scopes) against configured JWKS. +- `api_gateway` mints short-lived internal service tokens (`x-internal-service-token`) for service-to-service calls. +- Internal services (`analytics`, `bi_query`, `persistence`) require valid internal token on non-health endpoints and enforce issuer/type checks. +- Combine with K8s network controls (ClusterIP, NetworkPolicy, mTLS/service mesh where available). + +Frontend uses OIDC Authorization Code + PKCE with: +- `VITE_OIDC_ENABLED=true` +- `VITE_OIDC_AUTHORITY=` +- `VITE_OIDC_CLIENT_ID=` +- `VITE_OIDC_REDIRECT_URI=` +- `VITE_OIDC_POST_LOGOUT_REDIRECT_URI=` +- `VITE_OIDC_SCOPE=openid profile email` + +Backend security env: +- `REQUIRE_FRONTEND_AUTH=true` +- `FRONTEND_JWT_ISSUER_URL=` +- `FRONTEND_JWT_JWKS_URL=` +- `FRONTEND_JWT_AUDIENCE=` +- `FRONTEND_REQUIRED_SCOPES=` +- `INTERNAL_SERVICE_SHARED_SECRET=` +- `INTERNAL_SERVICE_ALLOWED_ISSUERS=api-gateway` +- `MSSQL_TRUST_SERVER_CERTIFICATE=false` and `POSTGRES_SSLMODE=require` for production TLS validation + +## Local Run (Microservices) + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -e . +cp .env.example .env +``` + +Run services in separate terminals: + +```bash +uvicorn microservices.persistence.main:app --host 0.0.0.0 --port 8103 --reload +uvicorn microservices.bi_query.main:app --host 0.0.0.0 --port 8101 --reload +uvicorn microservices.analytics.main:app --host 0.0.0.0 --port 8102 --reload +uvicorn microservices.api_gateway.main:app --host 0.0.0.0 --port 8000 --reload +``` + +Frontend: + +```bash +cd frontend +npm install +cp .env.example .env +npm run dev +``` + +Set: +- `VITE_API_BASE_URL=http://localhost:8000` +- `VITE_OTEL_COLLECTOR_ENDPOINT=http://alloy.monitoring.svc.cluster.local:4318` + +Frontend sends `Authorization: Bearer ` from the active OIDC session. + +## API Endpoints (via Gateway) + +- `GET /api/health` +- `GET /api/telemetry/status` +- `GET /api/kpis` +- `GET /api/history?days_back=365` +- `GET /api/forecasts?days=30` +- `GET /api/rankings?top_n=10` +- `GET /api/recommendations` +- `GET /api/dashboard` +- `GET /api/storage/audit-logs?limit=50` +- `GET /api/storage/forecasts?limit=50` +- `GET /api/storage/rankings?limit=50` +- `GET /api/storage/recommendations?limit=50` + +## K8s Deployment + +Example manifest: +- `k8s/microservices.yaml` + +It includes: +- namespace, config map, secret +- deployments/services for `api-gateway`, `bi-query`, `analytics`, `persistence` +- Alloy endpoint wiring via `OTEL_COLLECTOR_ENDPOINT` +- frontend JWT validation config and internal token secret wiring +- hardened pod security defaults (`runAsNonRoot`, dropped capabilities, `seccompProfile: RuntimeDefault`, no auto-mounted service account token) + +## Read-Only Guarantee + +- MSSQL connections enforce `ApplicationIntent=ReadOnly`. +- Warehouse query layer only accepts `SELECT`/`WITH`. +- Writable operations are isolated to PostgreSQL only. +- Use SQL Server account with `SELECT` grants only. + +## OTel Coverage + +- Frontend: + - W3C trace/baggage propagation + - document-load, user-interaction, fetch, XHR instrumentation + - manual dashboard spans +- Backend services: + - FastAPI request spans + - HTTP client spans for service-to-service calls + - SQLAlchemy spans (MSSQL and PostgreSQL) + - manual analytics + persistence spans + - audit/snapshot persistence telemetry + +## Verification + +1. Call `GET /api/telemetry/status` with a valid frontend bearer token. +2. Confirm response has non-null `trace_id` and `span_id`. +3. Trigger `GET /api/dashboard`; then verify records in `GET /api/storage/audit-logs`. +4. In Grafana/Tempo, confirm trace path includes: + - `api-gateway` span + - `analytics` span + - `bi-query` MSSQL spans + - `persistence` PostgreSQL spans +5. Call internal service endpoint directly without `x-internal-service-token` and verify it returns `401`. + +## Optional Tests + +```bash +cd backend +source .venv/bin/activate +pip install -e .[dev] +pytest +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..61098ab --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,59 @@ +APP_NAME=otel-bi-backend +APP_ENV=dev +LOG_LEVEL=INFO +API_HOST=0.0.0.0 +API_PORT=8000 + +CORS_ORIGINS=http://localhost:5173 + +MSSQL_HOST=localhost +MSSQL_PORT=1433 +MSSQL_USERNAME=readonly_user +MSSQL_PASSWORD=readonly_password +MSSQL_DRIVER=ODBC Driver 18 for SQL Server +MSSQL_TRUST_SERVER_CERTIFICATE=false + +WWI_DATABASE=WorldWideImporters +AW_DATABASE=AdventureWorks2022DWH +# Optional direct URLs (override generated URLs): +# WWI_CONNECTION_STRING=mssql+pyodbc://user:pass@host:1433/WorldWideImporters?driver=ODBC+Driver+18+for+SQL+Server&ApplicationIntent=ReadOnly +# AW_CONNECTION_STRING=mssql+pyodbc://user:pass@host:1433/AdventureWorks2022DWH?driver=ODBC+Driver+18+for+SQL+Server&ApplicationIntent=ReadOnly + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DATABASE=otel_bi_app +POSTGRES_USERNAME=otel_bi_app +POSTGRES_PASSWORD=otel_bi_app +POSTGRES_SSLMODE=require +# Optional direct URL: +# POSTGRES_CONNECTION_STRING=postgresql+psycopg://otel_bi_app:otel_bi_app@localhost:5432/otel_bi_app?sslmode=prefer +POSTGRES_REQUIRED=true + +QUERY_SERVICE_URL=http://localhost:8101 +ANALYTICS_SERVICE_URL=http://localhost:8102 +PERSISTENCE_SERVICE_URL=http://localhost:8103 +REQUEST_TIMEOUT_SECONDS=20 +REQUIRE_FRONTEND_AUTH=true +FRONTEND_JWT_ISSUER_URL=https:///realms/ +FRONTEND_JWT_AUDIENCE=otel-bi-api +FRONTEND_JWT_JWKS_URL=https:///realms//protocol/openid-connect/certs +FRONTEND_JWT_ALGORITHM=RS256 +FRONTEND_REQUIRED_SCOPES=openid profile email +FRONTEND_CLOCK_SKEW_SECONDS=30 +INTERNAL_SERVICE_AUTH_ENABLED=true +INTERNAL_SERVICE_SHARED_SECRET=replace-with-strong-random-secret-min-32-bytes +INTERNAL_SERVICE_TOKEN_TTL_SECONDS=120 +INTERNAL_SERVICE_TOKEN_AUDIENCE=bi-internal +INTERNAL_SERVICE_ALLOWED_ISSUERS=api-gateway +INTERNAL_TOKEN_CLOCK_SKEW_SECONDS=15 + +OTEL_SERVICE_NAME=otel-bi-backend +OTEL_SERVICE_NAMESPACE=final-thesis +OTEL_COLLECTOR_ENDPOINT=http://localhost:4318 +# K8s + Alloy example: +# OTEL_COLLECTOR_ENDPOINT=http://alloy.monitoring.svc.cluster.local:4318 +OTEL_EXPORT_TIMEOUT_MS=10000 + +FORECAST_HORIZON_DAYS=30 +DEFAULT_HISTORY_DAYS=365 +RANKING_DEFAULT_TOP_N=10 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..cc5e737 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Backend application package.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..33610e3 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from functools import lru_cache +from urllib.parse import quote_plus + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "otel-bi-backend" + app_env: str = "dev" + log_level: str = "INFO" + + api_host: str = "0.0.0.0" + api_port: int = 8000 + + cors_origins: str = "http://localhost:5173" + request_timeout_seconds: float = 20.0 + + mssql_host: str = "localhost" + mssql_port: int = 1433 + mssql_username: str = "sa" + mssql_password: str = "Password!123" + mssql_driver: str = "ODBC Driver 18 for SQL Server" + mssql_trust_server_certificate: bool = False + + wwi_database: str = "WorldWideImporters" + aw_database: str = "AdventureWorks2022DWH" + wwi_connection_string: str | None = None + aw_connection_string: str | None = None + postgres_host: str = "localhost" + postgres_port: int = 5432 + postgres_database: str = "otel_bi_app" + postgres_username: str = "otel_bi_app" + postgres_password: str = "otel_bi_app" + postgres_sslmode: str = "require" + postgres_connection_string: str | None = None + postgres_required: bool = True + query_service_url: str = "http://localhost:8101" + analytics_service_url: str = "http://localhost:8102" + persistence_service_url: str = "http://localhost:8103" + require_frontend_auth: bool = True + frontend_jwt_issuer_url: str = "" + frontend_jwt_audience: str = "" + frontend_jwt_jwks_url: str | None = None + frontend_jwt_algorithm: str = "RS256" + frontend_required_scopes: str = "" + frontend_clock_skew_seconds: int = Field(default=30, ge=0, le=300) + internal_service_auth_enabled: bool = True + internal_service_shared_secret: str = "change-me" + internal_service_token_ttl_seconds: int = Field(default=120, ge=30, le=900) + internal_service_token_audience: str = "bi-internal" + internal_service_allowed_issuers: str = "api-gateway" + internal_token_clock_skew_seconds: int = Field(default=15, ge=0, le=120) + + otel_service_name: str = "otel-bi-backend" + otel_service_namespace: str = "final-thesis" + otel_collector_endpoint: str = "http://localhost:4318" + otel_export_timeout_ms: int = 10000 + + forecast_horizon_days: int = Field(default=30, ge=7, le=180) + default_history_days: int = Field(default=365, ge=30, le=1460) + ranking_default_top_n: int = Field(default=10, ge=3, le=100) + storage_default_limit: int = Field(default=50, ge=10, le=500) + + @property + def cors_origins_list(self) -> list[str]: + return [ + origin.strip() for origin in self.cors_origins.split(",") if origin.strip() + ] + + @property + def frontend_required_scopes_list(self) -> list[str]: + return [ + scope.strip() + for scope in self.frontend_required_scopes.split(" ") + if scope.strip() + ] + + @property + def internal_service_allowed_issuers_list(self) -> list[str]: + return [ + issuer.strip() + for issuer in self.internal_service_allowed_issuers.split(",") + if issuer.strip() + ] + + def _build_mssql_connection_url(self, database: str) -> str: + driver = quote_plus(self.mssql_driver) + user = quote_plus(self.mssql_username) + password = quote_plus(self.mssql_password) + trust_cert = "yes" if self.mssql_trust_server_certificate else "no" + return ( + f"mssql+pyodbc://{user}:{password}@{self.mssql_host}:{self.mssql_port}/{database}" + f"?driver={driver}&TrustServerCertificate={trust_cert}&ApplicationIntent=ReadOnly" + ) + + @property + def wwi_connection_url(self) -> str: + return self.wwi_connection_string or self._build_mssql_connection_url( + self.wwi_database + ) + + @property + def aw_connection_url(self) -> str: + return self.aw_connection_string or self._build_mssql_connection_url( + self.aw_database + ) + + @property + def postgres_connection_url(self) -> str: + if self.postgres_connection_string: + return self.postgres_connection_string + + user = quote_plus(self.postgres_username) + password = quote_plus(self.postgres_password) + return ( + f"postgresql+psycopg://{user}:{password}@{self.postgres_host}:{self.postgres_port}/" + f"{self.postgres_database}?sslmode={self.postgres_sslmode}" + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/otel.py b/backend/app/core/otel.py new file mode 100644 index 0000000..d62eeac --- /dev/null +++ b/backend/app/core/otel.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from fastapi import FastAPI +from opentelemetry import metrics, trace +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.logging import LoggingInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.propagate import set_global_textmap +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + +try: + from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor +except ImportError: # pragma: no cover - defensive fallback for minimal envs + SystemMetricsInstrumentor = None # type: ignore[assignment] + +from app.core.config import Settings + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class TelemetryProviders: + tracer_provider: TracerProvider + meter_provider: MeterProvider + + +def configure_otel(settings: Settings) -> TelemetryProviders: + set_global_textmap( + CompositePropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()]) + ) + resource = Resource.create( + { + "service.name": settings.otel_service_name, + "service.namespace": settings.otel_service_namespace, + "deployment.environment": settings.app_env, + } + ) + + trace_exporter = OTLPSpanExporter( + endpoint=f"{settings.otel_collector_endpoint}/v1/traces", + timeout=settings.otel_export_timeout_ms / 1000, + ) + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter)) + trace.set_tracer_provider(tracer_provider) + + metric_reader = PeriodicExportingMetricReader( + exporter=OTLPMetricExporter( + endpoint=f"{settings.otel_collector_endpoint}/v1/metrics", + timeout=settings.otel_export_timeout_ms / 1000, + ), + export_interval_millis=10000, + ) + meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + + LoggingInstrumentor().instrument(set_logging_format=True) + if SystemMetricsInstrumentor is not None: + SystemMetricsInstrumentor().instrument() + else: + LOGGER.warning( + "System metrics instrumentor not available, runtime host metrics disabled." + ) + LOGGER.info("OpenTelemetry providers configured") + return TelemetryProviders( + tracer_provider=tracer_provider, meter_provider=meter_provider + ) + + +def instrument_fastapi(app: FastAPI) -> None: + FastAPIInstrumentor.instrument_app(app) + + +def instrument_sqlalchemy_engines(engines: dict[str, Any]) -> None: + for engine in engines.values(): + SQLAlchemyInstrumentor().instrument(engine=engine) + + +def instrument_httpx_clients() -> None: + HTTPXClientInstrumentor().instrument() + + +def shutdown_otel(providers: TelemetryProviders) -> None: + HTTPXClientInstrumentor().uninstrument() + if SystemMetricsInstrumentor is not None: + SystemMetricsInstrumentor().uninstrument() + LoggingInstrumentor().uninstrument() + providers.meter_provider.shutdown() + providers.tracer_provider.shutdown() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..0bf3713 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from time import time +from uuid import uuid4 + +import jwt +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jwt import InvalidTokenError, PyJWKClient + +from app.core.config import settings + +BEARER_SCHEME = HTTPBearer(auto_error=False) + + +@dataclass +class FrontendPrincipal: + subject: str + scopes: list[str] + claims: dict + token: str + + +@dataclass +class InternalPrincipal: + subject: str + scopes: list[str] + claims: dict + token: str + + +class FrontendJWTVerifier: + @property + def jwks_url(self) -> str: + if not settings.frontend_jwt_jwks_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="FRONTEND_JWT_JWKS_URL is not configured.", + ) + return settings.frontend_jwt_jwks_url + + @lru_cache(maxsize=1) + def _jwks_client(self) -> PyJWKClient: + return PyJWKClient(self.jwks_url) + + @staticmethod + def _extract_scopes(claims: dict) -> list[str]: + scope = claims.get("scope") + if isinstance(scope, str): + return [item for item in scope.split(" ") if item] + scp = claims.get("scp") + if isinstance(scp, list): + return [str(item) for item in scp] + return [] + + def verify(self, token: str) -> FrontendPrincipal: + if not settings.frontend_jwt_issuer_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="FRONTEND_JWT_ISSUER_URL is not configured.", + ) + if not settings.frontend_jwt_audience: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="FRONTEND_JWT_AUDIENCE is not configured.", + ) + + try: + signing_key = self._jwks_client().get_signing_key_from_jwt(token).key + claims = jwt.decode( + token, + key=signing_key, + algorithms=[settings.frontend_jwt_algorithm], + audience=settings.frontend_jwt_audience, + issuer=settings.frontend_jwt_issuer_url, + leeway=settings.frontend_clock_skew_seconds, + ) + except InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid frontend access token.", + ) from exc + + subject = str(claims.get("sub") or "") + if not subject: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Frontend token missing subject.", + ) + + scopes = self._extract_scopes(claims) + required = settings.frontend_required_scopes_list + missing = [scope for scope in required if scope not in scopes] + if missing: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required scope(s): {', '.join(missing)}", + ) + return FrontendPrincipal( + subject=subject, scopes=scopes, claims=claims, token=token + ) + + +class InternalTokenManager: + token_type = "internal-service" + + @staticmethod + def _assert_secret() -> str: + secret = settings.internal_service_shared_secret + if not secret or secret == "change-me": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="INTERNAL_SERVICE_SHARED_SECRET must be configured.", + ) + if len(secret.encode("utf-8")) < 32: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=( + "INTERNAL_SERVICE_SHARED_SECRET must be at least 32 bytes for " + "HS256 token signing." + ), + ) + return secret + + def mint( + self, + *, + subject: str, + scopes: list[str], + source_service: str, + ) -> str: + now = int(time()) + payload = { + "sub": subject, + "scope": " ".join(scopes), + "iss": source_service, + "aud": settings.internal_service_token_audience, + "typ": self.token_type, + "iat": now, + "nbf": now, + "exp": now + settings.internal_service_token_ttl_seconds, + "jti": str(uuid4()), + } + return jwt.encode(payload, self._assert_secret(), algorithm="HS256") + + def verify(self, token: str) -> InternalPrincipal: + try: + claims = jwt.decode( + token, + self._assert_secret(), + algorithms=["HS256"], + audience=settings.internal_service_token_audience, + options={ + "require": ["sub", "iss", "aud", "exp", "iat", "nbf", "jti", "typ"] + }, + leeway=settings.internal_token_clock_skew_seconds, + ) + except InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid internal service token.", + ) from exc + + subject = str(claims.get("sub") or "") + if not subject: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Internal token missing subject.", + ) + + issuer = str(claims.get("iss") or "") + if issuer not in settings.internal_service_allowed_issuers_list: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Internal token issuer is not allowed.", + ) + + token_type = str(claims.get("typ") or "") + if token_type != self.token_type: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Internal token type is invalid.", + ) + + scope = claims.get("scope") + scopes = [item for item in str(scope).split(" ") if item] if scope else [] + return InternalPrincipal( + subject=subject, scopes=scopes, claims=claims, token=token + ) + + +@lru_cache(maxsize=1) +def get_frontend_verifier() -> FrontendJWTVerifier: + return FrontendJWTVerifier() + + +@lru_cache(maxsize=1) +def get_internal_token_manager() -> InternalTokenManager: + return InternalTokenManager() + + +def require_frontend_principal( + credentials: HTTPAuthorizationCredentials | None = Depends(BEARER_SCHEME), +) -> FrontendPrincipal: + if not settings.require_frontend_auth: + return FrontendPrincipal(subject="anonymous", scopes=[], claims={}, token="") + + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing bearer token.", + ) + return get_frontend_verifier().verify(credentials.credentials) + + +def require_internal_principal( + internal_token: str | None = Header(default=None, alias="x-internal-service-token"), +) -> InternalPrincipal: + if not settings.internal_service_auth_enabled: + return InternalPrincipal( + subject="internal-unauth", scopes=[], claims={}, token="" + ) + + if not internal_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing x-internal-service-token header.", + ) + return get_internal_token_manager().verify(internal_token) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..d499bb3 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""Database helpers for warehouse connections.""" diff --git a/backend/app/db/engine.py b/backend/app/db/engine.py new file mode 100644 index 0000000..74f0578 --- /dev/null +++ b/backend/app/db/engine.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine + +from app.core.config import settings + + +def _create_read_only_engine(connection_url: str) -> Engine: + engine = create_engine( + connection_url, pool_pre_ping=True, pool_recycle=3600, future=True + ) + + @event.listens_for(engine, "connect") + def _on_connect(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED;") + finally: + cursor.close() + + return engine + + +def create_warehouse_engines() -> dict[str, Engine]: + return { + "wwi": _create_read_only_engine(settings.wwi_connection_url), + "aw": _create_read_only_engine(settings.aw_connection_url), + } + + +def dispose_engines(engines: dict[str, Engine]) -> None: + for engine in engines.values(): + engine.dispose() diff --git a/backend/app/db/postgres.py b/backend/app/db/postgres.py new file mode 100644 index 0000000..1570f3f --- /dev/null +++ b/backend/app/db/postgres.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings +from app.db.postgres_models import Base + + +def create_postgres_engine() -> Engine: + return create_engine( + settings.postgres_connection_url, + pool_pre_ping=True, + pool_recycle=3600, + future=True, + ) + + +def initialize_postgres_schema(engine: Engine) -> None: + Base.metadata.create_all(bind=engine) + + +def create_postgres_session_factory(engine: Engine) -> sessionmaker[Session]: + return sessionmaker( + bind=engine, autoflush=False, autocommit=False, expire_on_commit=False + ) diff --git a/backend/app/db/postgres_models.py b/backend/app/db/postgres_models.py new file mode 100644 index 0000000..66fc347 --- /dev/null +++ b/backend/app/db/postgres_models.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import JSON, DateTime, Float, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid4()) + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, index=True + ) + method: Mapped[str] = mapped_column(String(12), index=True) + path: Mapped[str] = mapped_column(String(300), index=True) + query_string: Mapped[str] = mapped_column(String(1000), default="") + status_code: Mapped[int] = mapped_column(Integer, index=True) + duration_ms: Mapped[float] = mapped_column(Float) + trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + span_id: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + client_ip: Mapped[str | None] = mapped_column(String(120), nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + details: Mapped[dict] = mapped_column(JSON, default=dict) + + +class ForecastRun(Base): + __tablename__ = "forecast_runs" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid4()) + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, index=True + ) + horizon_days: Mapped[int] = mapped_column(Integer) + point_count: Mapped[int] = mapped_column(Integer) + trigger_source: Mapped[str] = mapped_column(String(64), index=True) + trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + span_id: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + payload: Mapped[list[dict]] = mapped_column(JSON, default=list) + + +class RankingRun(Base): + __tablename__ = "ranking_runs" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid4()) + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, index=True + ) + top_n: Mapped[int] = mapped_column(Integer) + item_count: Mapped[int] = mapped_column(Integer) + trigger_source: Mapped[str] = mapped_column(String(64), index=True) + trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + span_id: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + payload: Mapped[list[dict]] = mapped_column(JSON, default=list) + + +class RecommendationRun(Base): + __tablename__ = "recommendation_runs" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid4()) + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, index=True + ) + item_count: Mapped[int] = mapped_column(Integer) + trigger_source: Mapped[str] = mapped_column(String(64), index=True) + trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + span_id: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + payload: Mapped[list[dict]] = mapped_column(JSON, default=list) diff --git a/backend/app/db/queries.py b/backend/app/db/queries.py new file mode 100644 index 0000000..eca75e5 --- /dev/null +++ b/backend/app/db/queries.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +AW_DAILY_SALES_QUERIES = [ + """ + SELECT + CAST(d.FullDateAlternateKey AS date) AS sale_date, + SUM(f.SalesAmount) AS revenue, + SUM(f.TotalProductCost) AS cost, + SUM(f.OrderQuantity) AS quantity, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales AS f + INNER JOIN dbo.DimDate AS d ON d.DateKey = f.OrderDateKey + GROUP BY CAST(d.FullDateAlternateKey AS date) + ORDER BY sale_date; + """, + """ + SELECT + CAST(OrderDate AS date) AS sale_date, + SUM(SalesAmount) AS revenue, + SUM(TotalProductCost) AS cost, + SUM(OrderQuantity) AS quantity, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales + GROUP BY CAST(OrderDate AS date) + ORDER BY sale_date; + """, +] + +WWI_DAILY_SALES_QUERIES = [ + """ + SELECT + CAST(i.InvoiceDate AS date) AS sale_date, + SUM(il.ExtendedPrice) AS revenue, + SUM(il.TaxAmount) AS cost, + SUM(il.Quantity) AS quantity, + COUNT_BIG(DISTINCT i.InvoiceID) AS orders + FROM Sales.Invoices AS i + INNER JOIN Sales.InvoiceLines AS il ON il.InvoiceID = i.InvoiceID + GROUP BY CAST(i.InvoiceDate AS date) + ORDER BY sale_date; + """, + """ + SELECT + CAST(i.InvoiceDate AS date) AS sale_date, + SUM(il.UnitPrice * il.Quantity) AS revenue, + CAST(0 AS float) AS cost, + SUM(il.Quantity) AS quantity, + COUNT_BIG(DISTINCT i.InvoiceID) AS orders + FROM Sales.Invoices AS i + INNER JOIN Sales.InvoiceLines AS il ON il.InvoiceID = i.InvoiceID + GROUP BY CAST(i.InvoiceDate AS date) + ORDER BY sale_date; + """, +] + +AW_PRODUCT_PERFORMANCE_QUERIES = [ + """ + SELECT + p.ProductAlternateKey AS product_id, + p.EnglishProductName AS product_name, + COALESCE(sc.EnglishProductSubcategoryName, 'Unknown') AS category_name, + SUM(f.SalesAmount) AS revenue, + SUM(f.TotalProductCost) AS cost, + SUM(f.OrderQuantity) AS quantity, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales AS f + INNER JOIN dbo.DimProduct AS p ON p.ProductKey = f.ProductKey + LEFT JOIN dbo.DimProductSubcategory AS sc ON sc.ProductSubcategoryKey = p.ProductSubcategoryKey + GROUP BY p.ProductAlternateKey, p.EnglishProductName, sc.EnglishProductSubcategoryName + ORDER BY revenue DESC; + """, + """ + SELECT + CAST(ProductKey AS nvarchar(100)) AS product_id, + CAST(ProductKey AS nvarchar(100)) AS product_name, + 'Unknown' AS category_name, + SUM(SalesAmount) AS revenue, + SUM(TotalProductCost) AS cost, + SUM(OrderQuantity) AS quantity, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales + GROUP BY ProductKey + ORDER BY revenue DESC; + """, +] + +WWI_PRODUCT_PERFORMANCE_QUERIES = [ + """ + SELECT + CAST(s.StockItemID AS nvarchar(100)) AS product_id, + s.StockItemName AS product_name, + COALESCE(cg.StockGroupName, 'Unknown') AS category_name, + SUM(il.ExtendedPrice) AS revenue, + SUM(il.TaxAmount) AS cost, + SUM(il.Quantity) AS quantity, + COUNT_BIG(*) AS orders + FROM Sales.InvoiceLines AS il + INNER JOIN Warehouse.StockItems AS s ON s.StockItemID = il.StockItemID + LEFT JOIN Warehouse.StockItemStockGroups AS sig ON sig.StockItemID = s.StockItemID + LEFT JOIN Warehouse.StockGroups AS cg ON cg.StockGroupID = sig.StockGroupID + GROUP BY s.StockItemID, s.StockItemName, cg.StockGroupName + ORDER BY revenue DESC; + """, + """ + SELECT + CAST(il.StockItemID AS nvarchar(100)) AS product_id, + CAST(il.StockItemID AS nvarchar(100)) AS product_name, + 'Unknown' AS category_name, + SUM(il.UnitPrice * il.Quantity) AS revenue, + CAST(0 AS float) AS cost, + SUM(il.Quantity) AS quantity, + COUNT_BIG(*) AS orders + FROM Sales.InvoiceLines AS il + GROUP BY il.StockItemID + ORDER BY revenue DESC; + """, +] + +AW_CUSTOMER_QUERIES = [ + """ + SELECT + CAST(c.CustomerAlternateKey AS nvarchar(100)) AS customer_id, + c.FirstName + ' ' + c.LastName AS customer_name, + SUM(f.SalesAmount) AS revenue, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales AS f + INNER JOIN dbo.DimCustomer AS c ON c.CustomerKey = f.CustomerKey + GROUP BY c.CustomerAlternateKey, c.FirstName, c.LastName + ORDER BY revenue DESC; + """, + """ + SELECT + CAST(CustomerKey AS nvarchar(100)) AS customer_id, + CAST(CustomerKey AS nvarchar(100)) AS customer_name, + SUM(SalesAmount) AS revenue, + COUNT_BIG(*) AS orders + FROM dbo.FactInternetSales + GROUP BY CustomerKey + ORDER BY revenue DESC; + """, +] + +WWI_CUSTOMER_QUERIES = [ + """ + SELECT + CAST(c.CustomerID AS nvarchar(100)) AS customer_id, + c.CustomerName AS customer_name, + SUM(il.ExtendedPrice) AS revenue, + COUNT_BIG(DISTINCT i.InvoiceID) AS orders + FROM Sales.Invoices AS i + INNER JOIN Sales.InvoiceLines AS il ON il.InvoiceID = i.InvoiceID + INNER JOIN Sales.Customers AS c ON c.CustomerID = i.CustomerID + GROUP BY c.CustomerID, c.CustomerName + ORDER BY revenue DESC; + """, + """ + SELECT + CAST(i.CustomerID AS nvarchar(100)) AS customer_id, + CAST(i.CustomerID AS nvarchar(100)) AS customer_name, + SUM(il.UnitPrice * il.Quantity) AS revenue, + COUNT_BIG(DISTINCT i.InvoiceID) AS orders + FROM Sales.Invoices AS i + INNER JOIN Sales.InvoiceLines AS il ON il.InvoiceID = i.InvoiceID + GROUP BY i.CustomerID + ORDER BY revenue DESC; + """, +] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..de2060f --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Business logic services.""" diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py new file mode 100644 index 0000000..048cecd --- /dev/null +++ b/backend/app/services/analytics_service.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from math import sqrt + +import numpy as np +import pandas as pd +from opentelemetry import trace +from sklearn.linear_model import LinearRegression + +from app.core.config import settings +from app.services.persistence_service import PersistenceService +from app.services.warehouse_service import ReadOnlyWarehouseClient + + +@dataclass +class DashboardSnapshot: + kpis: dict + history: list[dict] + forecasts: list[dict] + rankings: list[dict] + recommendations: list[dict] + + +class AnalyticsService: + def __init__( + self, + warehouse_client: ReadOnlyWarehouseClient, + persistence_service: PersistenceService | None = None, + ) -> None: + self.warehouse_client = warehouse_client + self.persistence_service = persistence_service + self.tracer = trace.get_tracer(__name__) + + @staticmethod + def _normalize_frame(df: pd.DataFrame, date_col: str = "sale_date") -> pd.DataFrame: + normalized = df.copy() + normalized[date_col] = pd.to_datetime(normalized[date_col], errors="coerce") + for numeric in ("revenue", "cost", "quantity", "orders"): + if numeric in normalized.columns: + normalized[numeric] = pd.to_numeric( + normalized[numeric], errors="coerce" + ).fillna(0.0) + return normalized.dropna(subset=[date_col]) + + def load_sales_history(self, days_back: int | None = None) -> pd.DataFrame: + with self.tracer.start_as_current_span("analytics.load_sales_history"): + daily_sales = self._normalize_frame( + self.warehouse_client.fetch_daily_sales() + ) + days = days_back or settings.default_history_days + min_date = pd.Timestamp(date.today() - timedelta(days=days)) + filtered = daily_sales[daily_sales["sale_date"] >= min_date] + return ( + filtered.groupby("sale_date", as_index=False)[ + ["revenue", "cost", "quantity", "orders"] + ] + .sum() + .sort_values("sale_date") + ) + + def get_kpis(self) -> dict: + with self.tracer.start_as_current_span("analytics.kpis"): + sales = self.load_sales_history(days_back=180) + if sales.empty: + return { + "total_revenue": 0.0, + "gross_margin_pct": 0.0, + "total_quantity": 0.0, + "avg_order_value": 0.0, + "records_in_window": 0, + } + + total_revenue = float(sales["revenue"].sum()) + total_cost = float(sales["cost"].sum()) + total_orders = max(float(sales["orders"].sum()), 1.0) + margin_pct = ( + ((total_revenue - total_cost) / total_revenue * 100) + if total_revenue + else 0.0 + ) + return { + "total_revenue": round(total_revenue, 2), + "gross_margin_pct": round(margin_pct, 2), + "total_quantity": round(float(sales["quantity"].sum()), 2), + "avg_order_value": round(total_revenue / total_orders, 2), + "records_in_window": int(sales.shape[0]), + } + + def get_history_points(self, days_back: int | None = None) -> list[dict]: + with self.tracer.start_as_current_span("analytics.history_points"): + sales = self.load_sales_history(days_back=days_back) + if sales.empty: + return [] + return [ + { + "date": pd.Timestamp(row["sale_date"]).date().isoformat(), + "revenue": round(float(row["revenue"]), 2), + "cost": round(float(row["cost"]), 2), + "quantity": round(float(row["quantity"]), 2), + } + for _, row in sales.iterrows() + ] + + def get_forecast( + self, + horizon_days: int | None = None, + *, + trigger_source: str = "api.forecasts", + persist: bool = True, + ) -> list[dict]: + with self.tracer.start_as_current_span("analytics.forecast"): + horizon = horizon_days or settings.forecast_horizon_days + sales = self.load_sales_history(days_back=720) + if sales.empty: + return [] + + series = ( + sales.set_index("sale_date")["revenue"] + .sort_index() + .resample("D") + .sum() + .fillna(0.0) + ) + y = series.values + x = np.arange(len(y), dtype=float).reshape(-1, 1) + model = LinearRegression() + model.fit(x, y) + baseline = model.predict(x) + residual = y - baseline + sigma = float(np.std(residual)) if len(residual) > 1 else 0.0 + + weekday_baseline = series.groupby(series.index.weekday).mean() + overall_mean = float(series.mean()) if len(series) else 0.0 + weekday_factor = ( + weekday_baseline / overall_mean + if overall_mean > 0 + else pd.Series([1.0] * 7, index=range(7)) + ) + weekday_factor = weekday_factor.replace([np.inf, -np.inf], 1.0).fillna(1.0) + + future_x = np.arange(len(y), len(y) + horizon, dtype=float).reshape(-1, 1) + raw_forecast = model.predict(future_x) + + predictions: list[dict] = [] + start_date = series.index.max().date() + for idx, point in enumerate(raw_forecast, start=1): + day = start_date + timedelta(days=idx) + factor = ( + float(weekday_factor.loc[day.weekday()]) + if day.weekday() in weekday_factor.index + else 1.0 + ) + yhat = max(float(point) * factor, 0.0) + ci = 1.96 * sigma * sqrt(1 + idx / max(len(y), 1)) + predictions.append( + { + "date": day.isoformat(), + "predicted_revenue": round(yhat, 2), + "lower_bound": round(max(yhat - ci, 0.0), 2), + "upper_bound": round(yhat + ci, 2), + } + ) + + if persist and self.persistence_service is not None: + span_context = trace.get_current_span().get_span_context() + trace_id = ( + f"{span_context.trace_id:032x}" if span_context.is_valid else None + ) + span_id = ( + f"{span_context.span_id:016x}" if span_context.is_valid else None + ) + self.persistence_service.record_forecast_run( + horizon_days=horizon, + payload=predictions, + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + ) + + return predictions + + def get_rankings( + self, + top_n: int | None = None, + *, + trigger_source: str = "api.rankings", + persist: bool = True, + ) -> list[dict]: + with self.tracer.start_as_current_span("analytics.rankings"): + n = top_n or settings.ranking_default_top_n + products = self.warehouse_client.fetch_product_performance().copy() + if products.empty: + return [] + + products["revenue"] = pd.to_numeric( + products["revenue"], errors="coerce" + ).fillna(0.0) + products["cost"] = pd.to_numeric(products["cost"], errors="coerce").fillna( + 0.0 + ) + products["quantity"] = pd.to_numeric( + products["quantity"], errors="coerce" + ).fillna(0.0) + products["orders"] = pd.to_numeric( + products["orders"], errors="coerce" + ).fillna(0.0) + + grouped = ( + products.groupby( + ["product_id", "product_name", "category_name"], as_index=False + )[["revenue", "cost", "quantity", "orders"]] + .sum() + .sort_values("revenue", ascending=False) + ) + + grouped["margin_pct"] = np.where( + grouped["revenue"] > 0, + ((grouped["revenue"] - grouped["cost"]) / grouped["revenue"]) * 100, + 0.0, + ) + + revenue_norm = grouped["revenue"] / max( + float(grouped["revenue"].max()), 1.0 + ) + margin_norm = (grouped["margin_pct"] + 100) / 200 + velocity_norm = grouped["quantity"] / max( + float(grouped["quantity"].max()), 1.0 + ) + grouped["score"] = ( + (0.55 * revenue_norm) + + (0.30 * margin_norm.clip(0, 1)) + + (0.15 * velocity_norm) + ) + ranked = ( + grouped.sort_values("score", ascending=False) + .head(n) + .reset_index(drop=True) + ) + + result = [ + { + "rank": int(idx + 1), + "product_id": str(row["product_id"]), + "product_name": str(row["product_name"]), + "category": str(row["category_name"]), + "revenue": round(float(row["revenue"]), 2), + "margin_pct": round(float(row["margin_pct"]), 2), + "score": round(float(row["score"]) * 100, 2), + } + for idx, row in ranked.iterrows() + ] + + if persist and self.persistence_service is not None: + span_context = trace.get_current_span().get_span_context() + trace_id = ( + f"{span_context.trace_id:032x}" if span_context.is_valid else None + ) + span_id = ( + f"{span_context.span_id:016x}" if span_context.is_valid else None + ) + self.persistence_service.record_ranking_run( + top_n=n, + payload=result, + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + ) + + return result + + def get_recommendations( + self, + rankings: list[dict] | None = None, + *, + trigger_source: str = "api.recommendations", + persist: bool = True, + ) -> list[dict]: + with self.tracer.start_as_current_span("analytics.recommendations"): + ranking_rows = ( + rankings + if rankings is not None + else self.get_rankings( + top_n=20, trigger_source=trigger_source, persist=persist + ) + ) + customers = self.warehouse_client.fetch_customer_performance().copy() + if customers.empty: + customers = pd.DataFrame(columns=["customer_name", "revenue", "orders"]) + + recommendations: list[dict] = [] + + if ranking_rows: + champion = ranking_rows[0] + recommendations.append( + { + "title": "Double down on champion SKU", + "priority": "high", + "summary": ( + f"Promote '{champion['product_name']}' with score {champion['score']:.2f} " + f"and margin {champion['margin_pct']:.2f}%." + ), + } + ) + + low_margin = next( + (row for row in ranking_rows if row["margin_pct"] < 10), None + ) + if low_margin: + recommendations.append( + { + "title": "Review pricing for low-margin bestseller", + "priority": "medium", + "summary": ( + f"'{low_margin['product_name']}' has strong rank but only " + f"{low_margin['margin_pct']:.2f}% margin." + ), + } + ) + + if not customers.empty: + customers["revenue"] = pd.to_numeric( + customers["revenue"], errors="coerce" + ).fillna(0.0) + customers["orders"] = pd.to_numeric( + customers["orders"], errors="coerce" + ).fillna(0.0) + customer = customers.sort_values("revenue", ascending=False).iloc[0] + recommendations.append( + { + "title": "Protect top customer relationship", + "priority": "high", + "summary": ( + f"Prioritize retention for '{customer['customer_name']}' with " + f"{float(customer['orders']):.0f} orders and {float(customer['revenue']):.2f} revenue." + ), + } + ) + + result = recommendations[:5] + if persist and self.persistence_service is not None: + span_context = trace.get_current_span().get_span_context() + trace_id = ( + f"{span_context.trace_id:032x}" if span_context.is_valid else None + ) + span_id = ( + f"{span_context.span_id:016x}" if span_context.is_valid else None + ) + self.persistence_service.record_recommendation_run( + payload=result, + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + ) + return result + + def get_dashboard(self) -> DashboardSnapshot: + with self.tracer.start_as_current_span("analytics.dashboard"): + rankings = self.get_rankings(trigger_source="api.dashboard", persist=True) + return DashboardSnapshot( + kpis=self.get_kpis(), + history=self.get_history_points(), + forecasts=self.get_forecast( + trigger_source="api.dashboard", persist=True + ), + rankings=rankings, + recommendations=self.get_recommendations( + rankings=rankings, + trigger_source="api.dashboard", + persist=True, + ), + ) diff --git a/backend/app/services/persistence_service.py b/backend/app/services/persistence_service.py new file mode 100644 index 0000000..02b4f5f --- /dev/null +++ b/backend/app/services/persistence_service.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import logging +from time import perf_counter + +from opentelemetry import metrics, trace +from sqlalchemy import desc, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from app.db.postgres_models import AuditLog, ForecastRun, RankingRun, RecommendationRun + +LOGGER = logging.getLogger(__name__) + + +class PersistenceService: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self.session_factory = session_factory + self.tracer = trace.get_tracer(__name__) + self.meter = metrics.get_meter(__name__) + self.write_counter = self.meter.create_counter( + name="postgres_persist_writes_total", + description="Total writes to app persistence PostgreSQL", + ) + self.write_latency = self.meter.create_histogram( + name="postgres_persist_write_latency_ms", + unit="ms", + description="Latency of app persistence write operations", + ) + + @staticmethod + def _to_audit_dict(row: AuditLog) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat(), + "method": row.method, + "path": row.path, + "query_string": row.query_string, + "status_code": row.status_code, + "duration_ms": row.duration_ms, + "trace_id": row.trace_id, + "span_id": row.span_id, + "client_ip": row.client_ip, + "user_agent": row.user_agent, + "details": row.details, + } + + @staticmethod + def _to_forecast_dict(row: ForecastRun) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat(), + "horizon_days": row.horizon_days, + "point_count": row.point_count, + "trigger_source": row.trigger_source, + "trace_id": row.trace_id, + "span_id": row.span_id, + "payload": row.payload, + } + + @staticmethod + def _to_ranking_dict(row: RankingRun) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat(), + "top_n": row.top_n, + "item_count": row.item_count, + "trigger_source": row.trigger_source, + "trace_id": row.trace_id, + "span_id": row.span_id, + "payload": row.payload, + } + + @staticmethod + def _to_recommendation_dict(row: RecommendationRun) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat(), + "item_count": row.item_count, + "trigger_source": row.trigger_source, + "trace_id": row.trace_id, + "span_id": row.span_id, + "payload": row.payload, + } + + def record_audit_log( + self, + *, + method: str, + path: str, + query_string: str, + status_code: int, + duration_ms: float, + trace_id: str | None, + span_id: str | None, + client_ip: str | None, + user_agent: str | None, + details: dict | None = None, + ) -> None: + started = perf_counter() + with self.tracer.start_as_current_span("persist.audit_log"): + try: + with self.session_factory() as session: + session.add( + AuditLog( + method=method, + path=path, + query_string=query_string[:1000], + status_code=status_code, + duration_ms=duration_ms, + trace_id=trace_id, + span_id=span_id, + client_ip=client_ip, + user_agent=user_agent, + details=details or {}, + ) + ) + session.commit() + self.write_counter.add( + 1, attributes={"entity": "audit", "status": "ok"} + ) + except SQLAlchemyError as exc: + LOGGER.exception("Failed to persist audit log: %s", exc) + self.write_counter.add( + 1, attributes={"entity": "audit", "status": "error"} + ) + finally: + self.write_latency.record( + (perf_counter() - started) * 1000, + attributes={"entity": "audit"}, + ) + + def record_forecast_run( + self, + *, + horizon_days: int, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + started = perf_counter() + with self.tracer.start_as_current_span("persist.forecast_run"): + try: + with self.session_factory() as session: + session.add( + ForecastRun( + horizon_days=horizon_days, + point_count=len(payload), + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + payload=payload, + ) + ) + session.commit() + self.write_counter.add( + 1, attributes={"entity": "forecast", "status": "ok"} + ) + except SQLAlchemyError as exc: + LOGGER.exception("Failed to persist forecast run: %s", exc) + self.write_counter.add( + 1, attributes={"entity": "forecast", "status": "error"} + ) + finally: + self.write_latency.record( + (perf_counter() - started) * 1000, + attributes={"entity": "forecast"}, + ) + + def record_ranking_run( + self, + *, + top_n: int, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + started = perf_counter() + with self.tracer.start_as_current_span("persist.ranking_run"): + try: + with self.session_factory() as session: + session.add( + RankingRun( + top_n=top_n, + item_count=len(payload), + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + payload=payload, + ) + ) + session.commit() + self.write_counter.add( + 1, attributes={"entity": "ranking", "status": "ok"} + ) + except SQLAlchemyError as exc: + LOGGER.exception("Failed to persist ranking run: %s", exc) + self.write_counter.add( + 1, attributes={"entity": "ranking", "status": "error"} + ) + finally: + self.write_latency.record( + (perf_counter() - started) * 1000, + attributes={"entity": "ranking"}, + ) + + def record_recommendation_run( + self, + *, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + started = perf_counter() + with self.tracer.start_as_current_span("persist.recommendation_run"): + try: + with self.session_factory() as session: + session.add( + RecommendationRun( + item_count=len(payload), + trigger_source=trigger_source, + trace_id=trace_id, + span_id=span_id, + payload=payload, + ) + ) + session.commit() + self.write_counter.add( + 1, attributes={"entity": "recommendation", "status": "ok"} + ) + except SQLAlchemyError as exc: + LOGGER.exception("Failed to persist recommendation run: %s", exc) + self.write_counter.add( + 1, attributes={"entity": "recommendation", "status": "error"} + ) + finally: + self.write_latency.record( + (perf_counter() - started) * 1000, + attributes={"entity": "recommendation"}, + ) + + def list_audit_logs(self, limit: int) -> list[dict]: + with self.tracer.start_as_current_span("persist.list_audit_logs"): + with self.session_factory() as session: + rows = session.execute( + select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit) + ).scalars() + return [self._to_audit_dict(row) for row in rows] + + def list_forecast_runs(self, limit: int) -> list[dict]: + with self.tracer.start_as_current_span("persist.list_forecast_runs"): + with self.session_factory() as session: + rows = session.execute( + select(ForecastRun) + .order_by(desc(ForecastRun.created_at)) + .limit(limit) + ).scalars() + return [self._to_forecast_dict(row) for row in rows] + + def list_ranking_runs(self, limit: int) -> list[dict]: + with self.tracer.start_as_current_span("persist.list_ranking_runs"): + with self.session_factory() as session: + rows = session.execute( + select(RankingRun) + .order_by(desc(RankingRun.created_at)) + .limit(limit) + ).scalars() + return [self._to_ranking_dict(row) for row in rows] + + def list_recommendation_runs(self, limit: int) -> list[dict]: + with self.tracer.start_as_current_span("persist.list_recommendation_runs"): + with self.session_factory() as session: + rows = session.execute( + select(RecommendationRun) + .order_by(desc(RecommendationRun.created_at)) + .limit(limit) + ).scalars() + return [self._to_recommendation_dict(row) for row in rows] diff --git a/backend/app/services/warehouse_service.py b/backend/app/services/warehouse_service.py new file mode 100644 index 0000000..9c2ce3a --- /dev/null +++ b/backend/app/services/warehouse_service.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib +import logging +from collections.abc import Sequence +from time import perf_counter + +import pandas as pd +from opentelemetry import metrics, trace +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + +from app.db import queries + +LOGGER = logging.getLogger(__name__) + + +class ReadOnlyWarehouseClient: + def __init__(self, engines: dict[str, Engine]) -> None: + self.engines = engines + self.tracer = trace.get_tracer(__name__) + self.meter = metrics.get_meter(__name__) + self.query_counter = self.meter.create_counter( + name="warehouse_queries_total", + description="Total warehouse query executions", + ) + self.query_latency = self.meter.create_histogram( + name="warehouse_query_latency_ms", + unit="ms", + description="Warehouse query latency", + ) + + def _validate_read_only_query(self, sql: str) -> None: + normalized = sql.strip().lower() + if not (normalized.startswith("select") or normalized.startswith("with")): + raise ValueError("Only read-only SELECT/CTE SQL statements are allowed.") + + def _run_query_list( + self, source: str, sql_candidates: Sequence[str] + ) -> pd.DataFrame: + engine = self.engines[source] + last_error: Exception | None = None + + for candidate in sql_candidates: + self._validate_read_only_query(candidate) + query_hash = hashlib.sha256(candidate.encode("utf-8")).hexdigest()[:12] + with self.tracer.start_as_current_span("warehouse.query") as span: + span.set_attribute("db.system", "mssql") + span.set_attribute("db.source", source) + span.set_attribute("db.query.hash", query_hash) + started = perf_counter() + try: + with engine.connect() as conn: + with self.tracer.start_as_current_span( + "warehouse.query.execute" + ): + df = pd.read_sql_query(sql=text(candidate), con=conn) + elapsed_ms = (perf_counter() - started) * 1000 + self.query_latency.record(elapsed_ms, attributes={"source": source}) + self.query_counter.add( + 1, attributes={"source": source, "status": "ok"} + ) + return df + except SQLAlchemyError as exc: + last_error = exc + elapsed_ms = (perf_counter() - started) * 1000 + self.query_latency.record(elapsed_ms, attributes={"source": source}) + self.query_counter.add( + 1, attributes={"source": source, "status": "error"} + ) + LOGGER.warning( + "Query failed for %s with hash %s: %s", source, query_hash, exc + ) + + if last_error is not None: + raise RuntimeError( + f"All query candidates failed for source '{source}'." + ) from last_error + return pd.DataFrame() + + def fetch_daily_sales(self) -> pd.DataFrame: + aw = self._run_query_list("aw", queries.AW_DAILY_SALES_QUERIES) + aw["source"] = "AdventureWorks2022DWH" + wwi = self._run_query_list("wwi", queries.WWI_DAILY_SALES_QUERIES) + wwi["source"] = "WorldWideImporters" + return pd.concat([aw, wwi], ignore_index=True) + + def fetch_product_performance(self) -> pd.DataFrame: + aw = self._run_query_list("aw", queries.AW_PRODUCT_PERFORMANCE_QUERIES) + aw["source"] = "AdventureWorks2022DWH" + wwi = self._run_query_list("wwi", queries.WWI_PRODUCT_PERFORMANCE_QUERIES) + wwi["source"] = "WorldWideImporters" + return pd.concat([aw, wwi], ignore_index=True) + + def fetch_customer_performance(self) -> pd.DataFrame: + aw = self._run_query_list("aw", queries.AW_CUSTOMER_QUERIES) + aw["source"] = "AdventureWorks2022DWH" + wwi = self._run_query_list("wwi", queries.WWI_CUSTOMER_QUERIES) + wwi["source"] = "WorldWideImporters" + return pd.concat([aw, wwi], ignore_index=True) diff --git a/backend/microservices/__init__.py b/backend/microservices/__init__.py new file mode 100644 index 0000000..a6b56f1 --- /dev/null +++ b/backend/microservices/__init__.py @@ -0,0 +1 @@ +"""Microservices package for BI platform.""" diff --git a/backend/microservices/analytics/__init__.py b/backend/microservices/analytics/__init__.py new file mode 100644 index 0000000..6528a9f --- /dev/null +++ b/backend/microservices/analytics/__init__.py @@ -0,0 +1 @@ +"""Analytics and forecasting microservice.""" diff --git a/backend/microservices/analytics/main.py b/backend/microservices/analytics/main.py new file mode 100644 index 0000000..a000ad0 --- /dev/null +++ b/backend/microservices/analytics/main.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from contextvars import ContextVar + +import httpx +import pandas as pd +from fastapi import Depends, FastAPI, Query, Request, Response + +from app.core.config import settings +from app.core.otel import ( + TelemetryProviders, + configure_otel, + instrument_fastapi, + instrument_httpx_clients, + shutdown_otel, +) +from app.core.security import InternalPrincipal, require_internal_principal +from app.services.analytics_service import AnalyticsService +from microservices.common.http import current_trace_headers, with_internal_service_token + +logging.basicConfig(level=settings.log_level) +LOGGER = logging.getLogger(__name__) + +FORWARD_HEADERS: ContextVar[dict[str, str]] = ContextVar("forward_headers", default={}) + + +class QueryWarehouseClient: + def __init__(self, client: httpx.Client, query_service_url: str) -> None: + self.client = client + self.query_service_url = query_service_url.rstrip("/") + + def _fetch(self, path: str) -> pd.DataFrame: + response = self.client.get( + f"{self.query_service_url}{path}", + headers=FORWARD_HEADERS.get(), + timeout=settings.request_timeout_seconds, + ) + response.raise_for_status() + return pd.DataFrame(response.json()) + + def fetch_daily_sales(self) -> pd.DataFrame: + return self._fetch("/internal/daily-sales") + + def fetch_product_performance(self) -> pd.DataFrame: + return self._fetch("/internal/product-performance") + + def fetch_customer_performance(self) -> pd.DataFrame: + return self._fetch("/internal/customer-performance") + + +class PersistenceProxy: + def __init__(self, client: httpx.Client, persistence_service_url: str) -> None: + self.client = client + self.persistence_service_url = persistence_service_url.rstrip("/") + + def _post(self, path: str, payload: dict) -> None: + response = self.client.post( + f"{self.persistence_service_url}{path}", + headers=FORWARD_HEADERS.get(), + json=payload, + timeout=settings.request_timeout_seconds, + ) + response.raise_for_status() + + def record_forecast_run( + self, + *, + horizon_days: int, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + self._post( + "/internal/forecast-runs", + { + "horizon_days": horizon_days, + "payload": payload, + "trigger_source": trigger_source, + "trace_id": trace_id, + "span_id": span_id, + }, + ) + + def record_ranking_run( + self, + *, + top_n: int, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + self._post( + "/internal/ranking-runs", + { + "top_n": top_n, + "payload": payload, + "trigger_source": trigger_source, + "trace_id": trace_id, + "span_id": span_id, + }, + ) + + def record_recommendation_run( + self, + *, + payload: list[dict], + trigger_source: str, + trace_id: str | None, + span_id: str | None, + ) -> None: + self._post( + "/internal/recommendation-runs", + { + "payload": payload, + "trigger_source": trigger_source, + "trace_id": trace_id, + "span_id": span_id, + }, + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + telemetry: TelemetryProviders = configure_otel(settings) + instrument_httpx_clients() + + http_client = httpx.Client() + warehouse_client = QueryWarehouseClient(http_client, settings.query_service_url) + persistence_proxy = PersistenceProxy(http_client, settings.persistence_service_url) + app.state.http_client = http_client + app.state.analytics = AnalyticsService(warehouse_client, persistence_proxy) + LOGGER.info("Analytics service ready") + yield + http_client.close() + shutdown_otel(telemetry) + + +app = FastAPI(title="analytics-service", version="0.1.0", lifespan=lifespan) +instrument_fastapi(app) + + +def _analytics() -> AnalyticsService: + return app.state.analytics + + +def _with_request_headers(request: Request): + headers = current_trace_headers() + incoming_internal = request.headers.get("x-internal-service-token") + if incoming_internal: + headers = with_internal_service_token(headers, incoming_internal) + token = FORWARD_HEADERS.set(headers) + return token + + +@app.get("/internal/health") +def health(request: Request, response: Response) -> dict: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return {"status": "ok", "service": "analytics-service"} + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/kpis") +def kpis( + request: Request, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return _analytics().get_kpis() + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/history") +def history( + request: Request, + response: Response, + days_back: int = Query(default=settings.default_history_days, ge=30, le=1460), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return _analytics().get_history_points(days_back=days_back) + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/forecasts") +def forecasts( + request: Request, + response: Response, + days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return _analytics().get_forecast( + horizon_days=days, trigger_source="analytics.api.forecasts", persist=True + ) + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/rankings") +def rankings( + request: Request, + response: Response, + top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return _analytics().get_rankings( + top_n=top_n, trigger_source="analytics.api.rankings", persist=True + ) + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/recommendations") +def recommendations( + request: Request, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + return _analytics().get_recommendations( + trigger_source="analytics.api.recommendations", persist=True + ) + finally: + FORWARD_HEADERS.reset(token) + + +@app.get("/internal/dashboard") +def dashboard( + request: Request, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + token = _with_request_headers(request) + try: + response.headers.update(current_trace_headers()) + snapshot = _analytics().get_dashboard() + return snapshot.__dict__ + finally: + FORWARD_HEADERS.reset(token) diff --git a/backend/microservices/api_gateway/__init__.py b/backend/microservices/api_gateway/__init__.py new file mode 100644 index 0000000..ddd8c90 --- /dev/null +++ b/backend/microservices/api_gateway/__init__.py @@ -0,0 +1 @@ +"""Public API gateway microservice.""" diff --git a/backend/microservices/api_gateway/main.py b/backend/microservices/api_gateway/main.py new file mode 100644 index 0000000..36b68cf --- /dev/null +++ b/backend/microservices/api_gateway/main.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from time import perf_counter + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.otel import ( + TelemetryProviders, + configure_otel, + instrument_fastapi, + instrument_httpx_clients, + shutdown_otel, +) +from app.core.security import ( + FrontendPrincipal, + get_internal_token_manager, + require_frontend_principal, +) +from microservices.common.http import current_trace_headers, with_internal_service_token + +logging.basicConfig(level=settings.log_level) +LOGGER = logging.getLogger(__name__) + + +def _raise_upstream(exc: httpx.HTTPStatusError) -> None: + detail = exc.response.text + raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc + + +@asynccontextmanager +async def lifespan(app: FastAPI): + telemetry: TelemetryProviders = configure_otel(settings) + instrument_httpx_clients() + app.state.http_client = httpx.Client() + LOGGER.info("API gateway ready") + yield + app.state.http_client.close() + shutdown_otel(telemetry) + + +app = FastAPI(title="api-gateway-service", version="0.1.0", lifespan=lifespan) +instrument_fastapi(app) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["GET", "POST"], + allow_headers=["*"], + expose_headers=["x-trace-id", "x-span-id"], +) + + +@app.middleware("http") +async def security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + response.headers["X-Permitted-Cross-Domain-Policies"] = "none" + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + return response + + +def _client() -> httpx.Client: + return app.state.http_client + + +def _upstream_headers(principal: FrontendPrincipal) -> dict[str, str]: + token = get_internal_token_manager().mint( + subject=principal.subject, + scopes=principal.scopes, + source_service="api-gateway", + ) + return with_internal_service_token(current_trace_headers(), token) + + +def _get_json(url: str, principal: FrontendPrincipal) -> dict | list: + try: + response = _client().get( + url, + headers=_upstream_headers(principal), + timeout=settings.request_timeout_seconds, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + _raise_upstream(exc) + + +def _audit_payload( + request: Request, response: Response, started: float, principal: FrontendPrincipal +) -> dict: + headers = current_trace_headers() + return { + "method": request.method, + "path": request.url.path, + "query_string": request.url.query, + "status_code": response.status_code, + "duration_ms": (perf_counter() - started) * 1000, + "trace_id": headers.get("x-trace-id"), + "span_id": headers.get("x-span-id"), + "client_ip": request.client.host if request.client else None, + "user_agent": request.headers.get("user-agent"), + "details": { + "subject": principal.subject, + "scopes": principal.scopes, + }, + } + + +def _persist_audit( + request: Request, response: Response, started: float, principal: FrontendPrincipal +) -> None: + if not request.url.path.startswith("/api/"): + return + try: + _client().post( + f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs", + headers=_upstream_headers(principal), + json=_audit_payload(request, response, started, principal), + timeout=settings.request_timeout_seconds, + ).raise_for_status() + except httpx.HTTPError as exc: + LOGGER.warning("Audit persistence failed: %s", exc) + + +@app.get("/api/health") +def health(response: Response) -> dict: + response.headers.update(current_trace_headers()) + return {"status": "ok", "service": "api-gateway-service"} + + +@app.get("/api/telemetry/status") +def telemetry_status( + request: Request, + response: Response, + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> dict: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = { + "status": "instrumented", + "service_name": "api-gateway-service", + "collector_endpoint": settings.otel_collector_endpoint, + "trace_id": current_trace_headers().get("x-trace-id"), + "span_id": current_trace_headers().get("x-span-id"), + "trace_headers": ["traceparent", "tracestate", "baggage", "x-trace-id"], + "subject": principal.subject, + } + _persist_audit(request, response, started, principal) + return payload + + +@app.get("/api/kpis") +def kpis( + request: Request, + response: Response, + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> dict: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/kpis", principal + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/history") +def history( + request: Request, + response: Response, + days_back: int = Query(default=settings.default_history_days, ge=30, le=1460), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/history?days_back={days_back}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/forecasts") +def forecasts( + request: Request, + response: Response, + days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/forecasts?days={days}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/rankings") +def rankings( + request: Request, + response: Response, + top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/rankings?top_n={top_n}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/recommendations") +def recommendations( + request: Request, + response: Response, + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/recommendations", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/dashboard") +def dashboard( + request: Request, + response: Response, + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> dict: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.analytics_service_url.rstrip('/')}/internal/dashboard", principal + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/storage/audit-logs") +def storage_audit_logs( + request: Request, + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs?limit={limit}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/storage/forecasts") +def storage_forecasts( + request: Request, + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.persistence_service_url.rstrip('/')}/internal/forecast-runs?limit={limit}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/storage/rankings") +def storage_rankings( + request: Request, + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.persistence_service_url.rstrip('/')}/internal/ranking-runs?limit={limit}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] + + +@app.get("/api/storage/recommendations") +def storage_recommendations( + request: Request, + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + principal: FrontendPrincipal = Depends(require_frontend_principal), +) -> list[dict]: + started = perf_counter() + response.headers.update(current_trace_headers()) + payload = _get_json( + f"{settings.persistence_service_url.rstrip('/')}/internal/recommendation-runs?limit={limit}", + principal, + ) + _persist_audit(request, response, started, principal) + return payload # type: ignore[return-value] diff --git a/backend/microservices/bi_query/__init__.py b/backend/microservices/bi_query/__init__.py new file mode 100644 index 0000000..5c794df --- /dev/null +++ b/backend/microservices/bi_query/__init__.py @@ -0,0 +1 @@ +"""Read-only MSSQL query microservice.""" diff --git a/backend/microservices/bi_query/main.py b/backend/microservices/bi_query/main.py new file mode 100644 index 0000000..3642795 --- /dev/null +++ b/backend/microservices/bi_query/main.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +import pandas as pd +from fastapi import Depends, FastAPI, Response + +from app.core.config import settings +from app.core.otel import ( + TelemetryProviders, + configure_otel, + instrument_fastapi, + instrument_sqlalchemy_engines, + shutdown_otel, +) +from app.core.security import InternalPrincipal, require_internal_principal +from app.db.engine import create_warehouse_engines, dispose_engines +from app.services.warehouse_service import ReadOnlyWarehouseClient +from microservices.common.http import current_trace_headers + +logging.basicConfig(level=settings.log_level) +LOGGER = logging.getLogger(__name__) + + +def _frame_to_rows(df: pd.DataFrame) -> list[dict]: + rows: list[dict] = [] + for _, row in df.iterrows(): + payload: dict = {} + for key, value in row.items(): + if hasattr(value, "isoformat"): + payload[str(key)] = value.isoformat() + else: + payload[str(key)] = value + rows.append(payload) + return rows + + +@asynccontextmanager +async def lifespan(app: FastAPI): + telemetry: TelemetryProviders = configure_otel(settings) + engines = create_warehouse_engines() + instrument_sqlalchemy_engines(engines) + app.state.query_client = ReadOnlyWarehouseClient(engines) + LOGGER.info("BI query service ready with read-only MSSQL engines") + yield + dispose_engines(engines) + shutdown_otel(telemetry) + + +app = FastAPI(title="bi-query-service", version="0.1.0", lifespan=lifespan) +instrument_fastapi(app) + + +@app.get("/internal/health") +def health(response: Response) -> dict: + response.headers.update(current_trace_headers()) + return {"status": "ok", "service": "bi-query-service"} + + +@app.get("/internal/daily-sales") +def daily_sales( + response: Response, _auth: InternalPrincipal = Depends(require_internal_principal) +) -> list[dict]: + response.headers.update(current_trace_headers()) + client: ReadOnlyWarehouseClient = app.state.query_client + return _frame_to_rows(client.fetch_daily_sales()) + + +@app.get("/internal/product-performance") +def product_performance( + response: Response, _auth: InternalPrincipal = Depends(require_internal_principal) +) -> list[dict]: + response.headers.update(current_trace_headers()) + client: ReadOnlyWarehouseClient = app.state.query_client + return _frame_to_rows(client.fetch_product_performance()) + + +@app.get("/internal/customer-performance") +def customer_performance( + response: Response, _auth: InternalPrincipal = Depends(require_internal_principal) +) -> list[dict]: + response.headers.update(current_trace_headers()) + client: ReadOnlyWarehouseClient = app.state.query_client + return _frame_to_rows(client.fetch_customer_performance()) diff --git a/backend/microservices/common/__init__.py b/backend/microservices/common/__init__.py new file mode 100644 index 0000000..966b495 --- /dev/null +++ b/backend/microservices/common/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for microservices.""" diff --git a/backend/microservices/common/http.py b/backend/microservices/common/http.py new file mode 100644 index 0000000..5b8d1ee --- /dev/null +++ b/backend/microservices/common/http.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from opentelemetry import trace + + +def current_trace_headers() -> dict[str, str]: + span_context = trace.get_current_span().get_span_context() + if not span_context.is_valid: + return {} + return { + "x-trace-id": f"{span_context.trace_id:032x}", + "x-span-id": f"{span_context.span_id:016x}", + } + + +def with_internal_service_token(headers: dict[str, str], token: str) -> dict[str, str]: + merged = dict(headers) + merged["x-internal-service-token"] = token + return merged diff --git a/backend/microservices/persistence/__init__.py b/backend/microservices/persistence/__init__.py new file mode 100644 index 0000000..0fcee13 --- /dev/null +++ b/backend/microservices/persistence/__init__.py @@ -0,0 +1 @@ +"""PostgreSQL persistence microservice.""" diff --git a/backend/microservices/persistence/main.py b/backend/microservices/persistence/main.py new file mode 100644 index 0000000..215330b --- /dev/null +++ b/backend/microservices/persistence/main.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, Query, Response +from pydantic import BaseModel, Field + +from app.core.config import settings +from app.core.otel import ( + TelemetryProviders, + configure_otel, + instrument_fastapi, + instrument_sqlalchemy_engines, + shutdown_otel, +) +from app.core.security import InternalPrincipal, require_internal_principal +from app.db.postgres import ( + create_postgres_engine, + create_postgres_session_factory, + initialize_postgres_schema, +) +from app.services.persistence_service import PersistenceService +from microservices.common.http import current_trace_headers + +logging.basicConfig(level=settings.log_level) +LOGGER = logging.getLogger(__name__) + + +class AuditLogIn(BaseModel): + method: str + path: str + query_string: str = "" + status_code: int + duration_ms: float + trace_id: str | None = None + span_id: str | None = None + client_ip: str | None = None + user_agent: str | None = None + details: dict = Field(default_factory=dict) + + +class ForecastRunIn(BaseModel): + horizon_days: int + payload: list[dict] + trigger_source: str + trace_id: str | None = None + span_id: str | None = None + + +class RankingRunIn(BaseModel): + top_n: int + payload: list[dict] + trigger_source: str + trace_id: str | None = None + span_id: str | None = None + + +class RecommendationRunIn(BaseModel): + payload: list[dict] + trigger_source: str + trace_id: str | None = None + span_id: str | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + telemetry: TelemetryProviders = configure_otel(settings) + engine = create_postgres_engine() + initialize_postgres_schema(engine) + instrument_sqlalchemy_engines({"appdb": engine}) + app.state.persistence_service = PersistenceService( + create_postgres_session_factory(engine) + ) + LOGGER.info("Persistence service ready with PostgreSQL") + yield + engine.dispose() + shutdown_otel(telemetry) + + +app = FastAPI(title="persistence-service", version="0.1.0", lifespan=lifespan) +instrument_fastapi(app) + + +def _service() -> PersistenceService: + return app.state.persistence_service + + +@app.get("/internal/health") +def health(response: Response) -> dict: + response.headers.update(current_trace_headers()) + return {"status": "ok", "service": "persistence-service"} + + +@app.post("/internal/audit-logs") +def create_audit_log( + payload: AuditLogIn, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + response.headers.update(current_trace_headers()) + _service().record_audit_log(**payload.model_dump()) + return {"status": "ok"} + + +@app.post("/internal/forecast-runs") +def create_forecast_run( + payload: ForecastRunIn, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + response.headers.update(current_trace_headers()) + _service().record_forecast_run(**payload.model_dump()) + return {"status": "ok"} + + +@app.post("/internal/ranking-runs") +def create_ranking_run( + payload: RankingRunIn, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + response.headers.update(current_trace_headers()) + _service().record_ranking_run(**payload.model_dump()) + return {"status": "ok"} + + +@app.post("/internal/recommendation-runs") +def create_recommendation_run( + payload: RecommendationRunIn, + response: Response, + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> dict: + response.headers.update(current_trace_headers()) + _service().record_recommendation_run(**payload.model_dump()) + return {"status": "ok"} + + +@app.get("/internal/audit-logs") +def list_audit_logs( + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + response.headers.update(current_trace_headers()) + return _service().list_audit_logs(limit=limit) + + +@app.get("/internal/forecast-runs") +def list_forecast_runs( + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + response.headers.update(current_trace_headers()) + return _service().list_forecast_runs(limit=limit) + + +@app.get("/internal/ranking-runs") +def list_ranking_runs( + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + response.headers.update(current_trace_headers()) + return _service().list_ranking_runs(limit=limit) + + +@app.get("/internal/recommendation-runs") +def list_recommendation_runs( + response: Response, + limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), + _auth: InternalPrincipal = Depends(require_internal_principal), +) -> list[dict]: + response.headers.update(current_trace_headers()) + return _service().list_recommendation_runs(limit=limit) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..fd12fb3 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "otel-bi-backend" +version = "0.1.0" +description = "OpenTelemetry-instrumented BI and forecasting backend for MSSQL data warehouses" +requires-python = ">=3.11" +license = "AGPL-3.0-or-later" +authors = [{ name = "Domagoj Andrić" }] +dependencies = [ + "fastapi>=0.116.0", + "uvicorn[standard]>=0.35.0", + "pydantic>=2.11.0", + "pydantic-settings>=2.10.0", + "python-dotenv>=1.1.0", + "httpx>=0.28.0", + "pyjwt[crypto]>=2.10.0", + "sqlalchemy>=2.0.40", + "pyodbc>=5.2.0", + "psycopg[binary]>=3.2.0", + "pandas>=2.3.0", + "numpy>=2.3.0", + "scikit-learn>=1.7.0", + "opentelemetry-api>=1.36.0", + "opentelemetry-sdk>=1.36.0", + "opentelemetry-exporter-otlp-proto-http>=1.36.0", + "opentelemetry-instrumentation-fastapi>=0.57b0", + "opentelemetry-instrumentation-httpx>=0.57b0", + "opentelemetry-instrumentation-sqlalchemy>=0.57b0", + "opentelemetry-instrumentation-logging>=0.57b0", + "opentelemetry-instrumentation-system-metrics>=0.57b0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4.0", +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*", "microservices*"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/backend/tests/test_analytics_service.py b/backend/tests/test_analytics_service.py new file mode 100644 index 0000000..382cbe0 --- /dev/null +++ b/backend/tests/test_analytics_service.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import date, timedelta + +import pandas as pd + +from app.services.analytics_service import AnalyticsService + + +class StubWarehouseClient: + def fetch_daily_sales(self) -> pd.DataFrame: + today = date.today() + rows = [] + for i in range(120): + day = today - timedelta(days=120 - i) + rows.append( + { + "sale_date": day.isoformat(), + "revenue": 1000 + (i * 5), + "cost": 500 + (i * 2), + "quantity": 40 + i, + "orders": 5 + (i % 4), + "source": "stub", + } + ) + return pd.DataFrame(rows) + + def fetch_product_performance(self) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "product_id": "A1", + "product_name": "Alpha", + "category_name": "CatA", + "revenue": 12000, + "cost": 6000, + "quantity": 400, + "orders": 150, + "source": "stub", + }, + { + "product_id": "B1", + "product_name": "Beta", + "category_name": "CatB", + "revenue": 9000, + "cost": 8500, + "quantity": 300, + "orders": 110, + "source": "stub", + }, + ] + ) + + def fetch_customer_performance(self) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "customer_id": "C1", + "customer_name": "Contoso", + "revenue": 15000, + "orders": 80, + "source": "stub", + } + ] + ) + + +def test_forecast_has_expected_horizon() -> None: + service = AnalyticsService(StubWarehouseClient()) # type: ignore[arg-type] + forecast = service.get_forecast(horizon_days=15) + assert len(forecast) == 15 + assert "predicted_revenue" in forecast[0] + + +def test_rankings_are_sorted() -> None: + service = AnalyticsService(StubWarehouseClient()) # type: ignore[arg-type] + rankings = service.get_rankings(top_n=2) + assert len(rankings) == 2 + assert rankings[0]["score"] >= rankings[1]["score"] diff --git a/backend/tests/test_security_tokens.py b/backend/tests/test_security_tokens.py new file mode 100644 index 0000000..7461e2f --- /dev/null +++ b/backend/tests/test_security_tokens.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from app.core.config import settings +from app.core.security import InternalTokenManager, require_internal_principal + + +def test_internal_token_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + settings, + "internal_service_shared_secret", + "unit-test-shared-secret-key-at-least-32b", + ) + monkeypatch.setattr(settings, "internal_service_token_audience", "bi-internal-test") + monkeypatch.setattr(settings, "internal_service_allowed_issuers", "api-gateway") + monkeypatch.setattr(settings, "internal_token_clock_skew_seconds", 0) + + manager = InternalTokenManager() + token = manager.mint( + subject="user-123", + scopes=["openid", "profile"], + source_service="api-gateway", + ) + + principal = manager.verify(token) + assert principal.subject == "user-123" + assert principal.claims["iss"] == "api-gateway" + assert principal.claims["typ"] == "internal-service" + + +def test_internal_token_rejects_untrusted_issuer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, + "internal_service_shared_secret", + "unit-test-shared-secret-key-at-least-32b", + ) + monkeypatch.setattr(settings, "internal_service_token_audience", "bi-internal-test") + monkeypatch.setattr(settings, "internal_service_allowed_issuers", "api-gateway") + monkeypatch.setattr(settings, "internal_token_clock_skew_seconds", 0) + + manager = InternalTokenManager() + token = manager.mint( + subject="user-123", + scopes=["openid"], + source_service="analytics", + ) + + with pytest.raises(HTTPException) as exc: + manager.verify(token) + assert exc.value.status_code == 401 + assert exc.value.detail == "Internal token issuer is not allowed." + + +def test_require_internal_principal_rejects_missing_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "internal_service_auth_enabled", True) + with pytest.raises(HTTPException) as exc: + require_internal_principal(None) + assert exc.value.status_code == 401 + assert exc.value.detail == "Missing x-internal-service-token header." diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..28de698 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +VITE_API_BASE_URL=http://localhost:8000 +VITE_OTEL_COLLECTOR_ENDPOINT=http://localhost:4318 +# K8s + Alloy example: +# VITE_OTEL_COLLECTOR_ENDPOINT=http://alloy.monitoring.svc.cluster.local:4318 +VITE_OTEL_SERVICE_NAME=otel-bi-frontend +VITE_OTEL_SERVICE_NAMESPACE=final-thesis + +VITE_OIDC_ENABLED=true +VITE_OIDC_AUTHORITY=https:///realms/ +VITE_OIDC_CLIENT_ID=otel-bi-frontend +VITE_OIDC_REDIRECT_URI=http://localhost:5173 +VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_OIDC_SCOPE=openid profile email diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3a78cfd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + OTel BI Command Center + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..eb708e1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2749 @@ +{ + "name": "otel-bi-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "otel-bi-frontend", + "version": "0.1.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-zone-peer-dep": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/instrumentation-document-load": "^0.58.0", + "@opentelemetry/instrumentation-fetch": "^0.213.0", + "@opentelemetry/instrumentation-user-interaction": "^0.57.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.213.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", + "@tanstack/react-query": "^5.90.2", + "oidc-client-ts": "^3.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.2.1", + "zone.js": "^0.15.1" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.9.2", + "vite": "^7.1.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "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.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "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.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz", + "integrity": "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-zone-peer-dep": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone-peer-dep/-/context-zone-peer-dep-2.6.0.tgz", + "integrity": "sha512-cFSbc+3Osyo3nBmdteNarJaI3GqTRn0YgcEJWt/PkgazLqfwMifPIoSBLpLCZQzGtkTbdef2htgE7Tw6qe/bkw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0", + "zone.js": "^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.213.0.tgz", + "integrity": "sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz", + "integrity": "sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-document-load/-/instrumentation-document-load-0.58.0.tgz", + "integrity": "sha512-szTyKkwz3BgHhlUZgQwmj3d9Xz6ZvYJAL/8wehsQyW9HAsfxfWVCaBTfnv/HmkX+zXtXSiwi3W4//WndJsiNsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.23.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.213.0.tgz", + "integrity": "sha512-A9Gr/iQ4bjQ4m6FOKierMOmI1/MGcRetmG7Y+/SrgV9aefT9/Fn4hFWHwbgZ4dATkEJQ5DIBXVn1sENOe/uQyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/instrumentation": "0.213.0", + "@opentelemetry/sdk-trace-web": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-user-interaction": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-user-interaction/-/instrumentation-user-interaction-0.57.0.tgz", + "integrity": "sha512-kw8RPVKIfWTgH2y0HS36QBrnSNvzyR+ULlnilXPqx9GNILxU8ZAQT7tA7Sct+5AL3NyLSLGse2hVMlTVoOtFCg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/sdk-trace-web": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0", + "zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.213.0.tgz", + "integrity": "sha512-Swuigd0YX5zQANch6lC0vSx63Z9ilB8/fJPn9iYBSif/SwPGmecHLawXpMM78PuxO/hIn8rSWP1gSWhBthLTKw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/instrumentation": "0.213.0", + "@opentelemetry/sdk-trace-web": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz", + "integrity": "sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-transformer": "0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", + "integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-metrics": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0", + "protobufjs": "^7.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", + "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz", + "integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.6.0.tgz", + "integrity": "sha512-xyYmLFatwUeYnB7NtQ2Ydl9Y8uiblN+EDo5YEjnk7ZRMhGFyt1wgPqb8EYvATLuDiRVtxid1fJsL6RH1fCQMIA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "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-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "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/@tanstack/query-core": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.3.tgz", + "integrity": "sha512-D8jsCexxS5crZxAeiH6VlLHOUzmHOxeW5c11y8rZu0c34u/cy18hUKQXA/gn1Ila3ZIFzP+Pzv76YnliC0EtZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.91.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": 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.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@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 || ^8.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "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/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "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/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "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.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "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/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/import-in-the-middle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz", + "integrity": "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/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/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/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "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/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "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.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "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/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "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/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "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/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.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/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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/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.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/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/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.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a14d1b7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "otel-bi-frontend", + "version": "0.1.0", + "private": true, + "license": "AGPL-3.0-or-later", + "author": "Domagoj Andrić", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-zone-peer-dep": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/instrumentation-document-load": "^0.58.0", + "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/instrumentation-fetch": "^0.213.0", + "@opentelemetry/instrumentation-user-interaction": "^0.57.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.213.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", + "@tanstack/react-query": "^5.90.2", + "oidc-client-ts": "^3.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.2.1", + "zone.js": "^0.15.1" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.9.2", + "vite": "^7.1.4" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..fec3c15 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,363 @@ +import { trace, SpanStatusCode } from "@opentelemetry/api"; +import { useQuery } from "@tanstack/react-query"; +import { startTransition, useDeferredValue } from "react"; +import { + Area, + AreaChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { getDashboard } from "./api/client"; +import { useAuth } from "./auth/AuthContext"; + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +const tracer = trace.getTracer("bi-frontend-ui"); + +function formatCompactDate(value: string): string { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function formatTooltipMoney( + value: string | number | readonly (string | number)[] | undefined, +): string { + const raw = Array.isArray(value) ? Number(value[0]) : Number(value); + return money.format(Number.isFinite(raw) ? raw : 0); +} + +function formatTooltipNumber( + value: string | number | readonly (string | number)[] | undefined, +): string { + const raw = Array.isArray(value) ? Number(value[0]) : Number(value); + return Number.isFinite(raw) ? raw.toFixed(2) : "0.00"; +} + +export default function App() { + const auth = useAuth(); + const dashboardQuery = useQuery({ + queryKey: ["dashboard"], + queryFn: getDashboard, + staleTime: 30_000, + refetchInterval: 120_000, + enabled: auth.authenticated || !auth.enabled, + }); + + const deferredRankings = useDeferredValue( + dashboardQuery.data?.rankings ?? [], + ); + + const chartHistory = + dashboardQuery.data?.history.slice(-120).map((point) => ({ + date: point.date, + actual: point.revenue, + forecast: null as number | null, + lower: null as number | null, + upper: null as number | null, + })) ?? []; + const chartForecast = + dashboardQuery.data?.forecasts.slice(0, 45).map((point) => ({ + date: point.date, + actual: null as number | null, + forecast: point.predicted_revenue, + lower: point.lower_bound, + upper: point.upper_bound, + })) ?? []; + const trendData = [...chartHistory, ...chartForecast]; + + const refreshData = () => { + tracer.startActiveSpan("frontend.refresh_click", async (span) => { + try { + startTransition(() => { + void dashboardQuery.refetch(); + }); + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "Failed to refresh dashboard data.", + }); + } finally { + span.end(); + } + }); + }; + + if (auth.loading) { + return
Initializing OIDC session...
; + } + + if (auth.error) { + return ( +
+ Authentication setup error. +
+ {auth.error} +
+ ); + } + + if (auth.enabled && !auth.authenticated) { + return ( +
+ Authentication required. +
+ +
+ ); + } + + if (dashboardQuery.isLoading) { + return ( +
+ Loading telemetry-enabled BI dashboard... +
+ ); + } + + if (dashboardQuery.error || !dashboardQuery.data) { + return ( +
+ Dashboard could not load. +
+ {(dashboardQuery.error as Error | undefined)?.message ?? + "No response from backend."} +
+ ); + } + + const { kpis, recommendations, telemetry } = dashboardQuery.data; + const topScore = deferredRankings[0]?.score ?? 0; + + return ( +
+
+
+
+

Business Intelligence Command Center

+

Warehouse Forecasting and Ranking Dashboard

+

+ Data sources: WorldWideImporters +{" "} + AdventureWorks2022DWH (read-only) with + OpenTelemetry traces from browser to SQL. +

+

+ Last backend trace:{" "} + {telemetry.backendTraceId ?? "missing-trace-id-header"} +

+
+
+

+ User: {auth.subject ?? "unknown"} +

+
+ + {auth.enabled ? ( + + ) : null} +
+
+
+ +
+
+

Total Revenue

+

{money.format(kpis.total_revenue)}

+
+
+

Gross Margin

+

{kpis.gross_margin_pct.toFixed(2)}%

+
+
+

Avg Order Value

+

{money.format(kpis.avg_order_value)}

+
+
+

Total Quantity

+

+ {kpis.total_quantity.toLocaleString("en-US", { + maximumFractionDigits: 0, + })} +

+
+
+ +
+
+
+

Revenue Trend + Forecast

+ {trendData.length} points +
+
+ + + + + money.format(value)} + stroke="rgba(255,255,255,0.65)" + /> + + new Date(label).toLocaleDateString("en-US") + } + formatter={formatTooltipMoney} + /> + + + + + + +
+
+ +
+
+

Top Product Score

+ Weighted ranking index +
+
+ + + + + + + + + +

+ Current leader score {topScore.toFixed(2)} / 100 +

+
+
+ +
+
+

Product Rankings

+ Top {deferredRankings.length} +
+
+ + + + + + + + + + + + + {deferredRankings.map((item) => ( + + + + + + + + + ))} + +
RankProductCategoryRevenueMarginScore
{item.rank}{item.product_name}{item.category}{money.format(item.revenue)}{item.margin_pct.toFixed(2)}%{item.score.toFixed(2)}
+
+
+ +
+
+

Recommendations

+ Action queue +
+
    + {recommendations.map((item, index) => ( +
  • + + {item.priority} + +

    {item.title}

    +

    {item.summary}

    +
  • + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..8b6a254 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,53 @@ +import { SpanStatusCode, trace } from "@opentelemetry/api"; + +import { currentAccessToken } from "../auth/oidc"; +import type { DashboardPayload, DashboardResponse } from "./types"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; +const tracer = trace.getTracer("bi-frontend-api"); + +async function parseJson(response: Response): Promise { + if (!response.ok) { + const body = await response.text(); + throw new Error(`HTTP ${response.status}: ${body}`); + } + return (await response.json()) as T; +} + +export async function getDashboard(): Promise { + return tracer.startActiveSpan("frontend.api.dashboard", async (span) => { + try { + const token = currentAccessToken(); + const response = await fetch(`${API_BASE_URL}/api/dashboard`, { + method: "GET", + headers: { + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + const data = await parseJson(response); + const backendTraceId = response.headers.get("x-trace-id"); + const backendSpanId = response.headers.get("x-span-id"); + span.setAttribute("dashboard.kpis", Object.keys(data.kpis).length); + span.setAttribute("backend.trace_id_present", backendTraceId !== null); + span.setStatus({ code: SpanStatusCode.OK }); + return { + ...data, + telemetry: { + backendTraceId, + backendSpanId, + }, + }; + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "dashboard request failed", + }); + throw error; + } finally { + span.end(); + } + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..326cb2d --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,52 @@ +export type KPI = { + total_revenue: number; + gross_margin_pct: number; + total_quantity: number; + avg_order_value: number; + records_in_window: number; +}; + +export type HistoryPoint = { + date: string; + revenue: number; + cost: number; + quantity: number; +}; + +export type ForecastPoint = { + date: string; + predicted_revenue: number; + lower_bound: number; + upper_bound: number; +}; + +export type RankingItem = { + rank: number; + product_id: string; + product_name: string; + category: string; + revenue: number; + margin_pct: number; + score: number; +}; + +export type Recommendation = { + title: string; + priority: string; + summary: string; +}; + +export type DashboardResponse = { + kpis: KPI; + history: HistoryPoint[]; + forecasts: ForecastPoint[]; + rankings: RankingItem[]; + recommendations: Recommendation[]; +}; + +export type DashboardPayload = DashboardResponse & { + telemetry: { + backendTraceId: string | null; + backendSpanId: string | null; + }; +}; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..4eb0947 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,90 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; + +import { + currentUser, + initializeOIDC, + isOIDCEnabled, + login, + logout, + oidcConfigError, +} from "./oidc"; + +type AuthState = { + loading: boolean; + authenticated: boolean; + enabled: boolean; + subject: string | null; + error: string | null; + login: () => Promise; + logout: () => Promise; +}; + +const AuthContext = createContext({ + loading: true, + authenticated: false, + enabled: true, + subject: null, + error: null, + login, + logout, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [loading, setLoading] = useState(true); + const [authenticated, setAuthenticated] = useState(false); + const [subject, setSubject] = useState(null); + const [error, setError] = useState(null); + const enabled = isOIDCEnabled(); + + useEffect(() => { + const bootstrap = async () => { + try { + const configIssue = oidcConfigError(); + if (configIssue) { + setError(configIssue); + setAuthenticated(false); + return; + } + + await initializeOIDC(); + const user = currentUser(); + const isAuthed = !!user && !user.expired; + setAuthenticated(isAuthed); + setSubject((user?.profile?.sub as string | undefined) ?? null); + } catch (err) { + setError((err as Error).message); + setAuthenticated(false); + } finally { + setLoading(false); + } + }; + + void bootstrap(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthState { + return useContext(AuthContext); +} diff --git a/frontend/src/auth/oidc.ts b/frontend/src/auth/oidc.ts new file mode 100644 index 0000000..6a4bd7b --- /dev/null +++ b/frontend/src/auth/oidc.ts @@ -0,0 +1,105 @@ +import { UserManager, type User, WebStorageStateStore } from "oidc-client-ts"; + +type OIDCConfig = { + enabled: boolean; + authority: string; + clientId: string; + redirectUri: string; + postLogoutRedirectUri: string; + scope: string; +}; + +let cachedUser: User | null = null; + +function config(): OIDCConfig { + const enabled = (import.meta.env.VITE_OIDC_ENABLED ?? "true") !== "false"; + return { + enabled, + authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "", + clientId: import.meta.env.VITE_OIDC_CLIENT_ID ?? "", + redirectUri: + import.meta.env.VITE_OIDC_REDIRECT_URI ?? window.location.origin, + postLogoutRedirectUri: + import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ?? + window.location.origin, + scope: import.meta.env.VITE_OIDC_SCOPE ?? "openid profile email", + }; +} + +export function isOIDCEnabled(): boolean { + return config().enabled; +} + +export function oidcConfigError(): string | null { + const cfg = config(); + if (!cfg.enabled) return null; + if (!cfg.authority) return "VITE_OIDC_AUTHORITY is not set."; + if (!cfg.clientId) return "VITE_OIDC_CLIENT_ID is not set."; + return null; +} + +function manager(): UserManager { + const cfg = config(); + return new UserManager({ + authority: cfg.authority, + client_id: cfg.clientId, + redirect_uri: cfg.redirectUri, + post_logout_redirect_uri: cfg.postLogoutRedirectUri, + response_type: "code", + scope: cfg.scope, + userStore: new WebStorageStateStore({ store: window.sessionStorage }), + monitorSession: true, + automaticSilentRenew: false, + }); +} + +function hasSigninParams(): boolean { + const params = new URLSearchParams(window.location.search); + return params.has("code") && params.has("state"); +} + +export async function initializeOIDC(): Promise { + if (!isOIDCEnabled()) { + cachedUser = null; + return null; + } + + if (oidcConfigError()) { + cachedUser = null; + return null; + } + + const userManager = manager(); + if (hasSigninParams()) { + cachedUser = await userManager.signinRedirectCallback(); + window.history.replaceState({}, document.title, window.location.pathname); + return cachedUser; + } + + cachedUser = await userManager.getUser(); + return cachedUser; +} + +export function currentUser(): User | null { + return cachedUser; +} + +export function currentAccessToken(): string | null { + if (cachedUser?.access_token && !cachedUser.expired) + return cachedUser.access_token; + return null; +} + +export async function login(): Promise { + if (!isOIDCEnabled()) return; + const userManager = manager(); + await userManager.signinRedirect({ + state: { returnTo: window.location.pathname + window.location.search }, + }); +} + +export async function logout(): Promise { + if (!isOIDCEnabled()) return; + const userManager = manager(); + await userManager.signoutRedirect(); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6e4bf8a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,31 @@ +import "zone.js"; +import "./styles.css"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; +import { AuthProvider } from "./auth/AuthContext"; +import { setupTelemetry } from "./telemetry"; + +setupTelemetry(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..38d5d5a --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,325 @@ +:root { + font-family: "Space Grotesk", "Segoe UI", sans-serif; + line-height: 1.5; + font-weight: 400; + color: #f3f7ff; + background: #0a1019; + + --bg-primary: #0a1019; + --bg-secondary: #101d2e; + --bg-panel: rgba(16, 28, 44, 0.72); + --border: rgba(186, 212, 255, 0.22); + --accent-a: #f9de70; + --accent-b: #57d4ff; + --accent-c: #8ef2c7; + --text-muted: rgba(233, 244, 255, 0.7); + --shadow: 0 20px 55px rgba(3, 8, 16, 0.45); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient( + circle at 0% 0%, + rgba(122, 82, 242, 0.2), + transparent 30% + ), + radial-gradient( + circle at 100% 10%, + rgba(87, 212, 255, 0.18), + transparent 30% + ), + linear-gradient(150deg, var(--bg-primary), var(--bg-secondary)); +} + +.app-shell { + width: min(1200px, 100% - 2rem); + margin: 1.5rem auto 3rem; + position: relative; +} + +.radial-glow { + position: fixed; + width: 48vw; + height: 48vw; + max-width: 540px; + max-height: 540px; + border-radius: 50%; + background: radial-gradient( + circle, + rgba(87, 212, 255, 0.16), + transparent 65% + ); + top: -12rem; + right: -10rem; + pointer-events: none; + z-index: 0; +} + +.dashboard-header, +.kpi-grid, +.panel-grid { + position: relative; + z-index: 1; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.auth-actions { + display: grid; + gap: 0.5rem; + justify-items: end; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +.dashboard-header h1 { + margin: 0.2rem 0 0.5rem; + font-size: clamp(1.6rem, 2.2vw, 2.4rem); + letter-spacing: -0.04em; +} + +.eyebrow { + margin: 0; + color: var(--accent-b); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.74rem; + font-weight: 600; +} + +.subtitle { + margin: 0; + color: var(--text-muted); + max-width: 74ch; +} + +.trace-id { + margin: 0.5rem 0 0; + color: var(--text-muted); + font-size: 0.8rem; +} + +.trace-id code { + color: var(--accent-c); +} + +.refresh-button { + background: linear-gradient(125deg, var(--accent-b), #7be8ff); + color: #04111b; + border: 0; + font-weight: 700; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + box-shadow: var(--shadow); + cursor: pointer; +} + +.logout-button { + background: rgba(248, 159, 159, 0.2); + color: #ffd6d6; + border: 1px solid rgba(255, 184, 184, 0.45); + font-weight: 700; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + box-shadow: var(--shadow); + cursor: pointer; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.9rem; + margin-bottom: 0.9rem; +} + +.kpi-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 1rem; + padding: 0.95rem 1rem; + box-shadow: var(--shadow); + backdrop-filter: blur(8px); +} + +.kpi-card p { + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.kpi-card h2 { + margin: 0.5rem 0 0; + font-size: clamp(1.1rem, 1.7vw, 1.6rem); +} + +.panel-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 0.9rem; +} + +.panel { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: var(--shadow); + backdrop-filter: blur(8px); + padding: 0.9rem; +} + +.panel.wide { + grid-column: span 2; +} + +.panel-title-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; +} + +.panel-title-row h3 { + margin: 0; +} + +.panel-title-row span { + color: var(--text-muted); + font-size: 0.85rem; +} + +.chart-wrap, +.score-wrap { + margin-top: 0.8rem; +} + +.score-caption { + margin-top: 0.4rem; + color: var(--text-muted); +} + +.table-wrap { + margin-top: 0.7rem; + max-height: 350px; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 0.6rem 0.45rem; + border-bottom: 1px solid rgba(216, 232, 255, 0.09); + white-space: nowrap; +} + +th { + color: var(--text-muted); + font-size: 0.77rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.recommendations-list { + margin: 0.8rem 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.75rem; +} + +.recommendations-list li { + border: 1px solid rgba(190, 210, 245, 0.14); + background: rgba(12, 20, 31, 0.7); + border-radius: 0.8rem; + padding: 0.75rem; +} + +.recommendations-list h4 { + margin: 0.5rem 0 0.3rem; +} + +.recommendations-list p { + margin: 0; + color: var(--text-muted); +} + +.priority { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-size: 0.72rem; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.08em; +} + +.priority.high { + background: rgba(255, 112, 112, 0.17); + color: #ffb6b6; +} + +.priority.medium { + background: rgba(255, 205, 112, 0.18); + color: #ffe3ae; +} + +.priority.low { + background: rgba(142, 242, 199, 0.18); + color: #b9ffd8; +} + +.loading-shell { + color: #d6e7ff; + min-height: 100vh; + display: grid; + place-items: center; + text-align: center; + padding: 1rem; + font-size: 1.04rem; +} + +@media (max-width: 980px) { + .kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .panel-grid { + grid-template-columns: 1fr; + } + + .panel.wide { + grid-column: auto; + } +} + +@media (max-width: 640px) { + .dashboard-header { + flex-direction: column; + } + + .kpi-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/telemetry.ts b/frontend/src/telemetry.ts new file mode 100644 index 0000000..de3c958 --- /dev/null +++ b/frontend/src/telemetry.ts @@ -0,0 +1,77 @@ +import { propagation } from "@opentelemetry/api"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { + CompositePropagator, + W3CBaggagePropagator, + W3CTraceContextPropagator, +} from "@opentelemetry/core"; +import { DocumentLoadInstrumentation } from "@opentelemetry/instrumentation-document-load"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; +import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch"; +import { UserInteractionInstrumentation } from "@opentelemetry/instrumentation-user-interaction"; +import { XMLHttpRequestInstrumentation } from "@opentelemetry/instrumentation-xml-http-request"; +import { ZoneContextManager } from "@opentelemetry/context-zone-peer-dep"; + +let initialized = false; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function setupTelemetry(): void { + if (initialized) return; + initialized = true; + + const endpoint = + import.meta.env.VITE_OTEL_COLLECTOR_ENDPOINT ?? "http://localhost:4318"; + const serviceName = + import.meta.env.VITE_OTEL_SERVICE_NAME ?? "otel-bi-frontend"; + const serviceNamespace = + import.meta.env.VITE_OTEL_SERVICE_NAMESPACE ?? "final-thesis"; + const apiBaseUrl = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; + + propagation.setGlobalPropagator( + new CompositePropagator({ + propagators: [ + new W3CTraceContextPropagator(), + new W3CBaggagePropagator(), + ], + }), + ); + + const provider = new WebTracerProvider({ + resource: resourceFromAttributes({ + "service.name": serviceName, + "service.namespace": serviceNamespace, + "deployment.environment": import.meta.env.MODE, + }), + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: `${endpoint}/v1/traces`, + }), + ), + ], + }); + + provider.register({ + contextManager: new ZoneContextManager(), + }); + + registerInstrumentations({ + instrumentations: [ + new DocumentLoadInstrumentation(), + new FetchInstrumentation({ + propagateTraceHeaderCorsUrls: [ + new RegExp(`^${escapeRegExp(apiBaseUrl)}`), + ], + }), + new XMLHttpRequestInstrumentation(), + new UserInteractionInstrumentation(), + ], + }); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a5908f4 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..bcb52aa --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c2144bd --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 5173, + }, +}); diff --git a/k8s/microservices.yaml b/k8s/microservices.yaml new file mode 100644 index 0000000..1dc2c10 --- /dev/null +++ b/k8s/microservices.yaml @@ -0,0 +1,277 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: bi-platform +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: bi-platform-config + namespace: bi-platform +data: + APP_ENV: "prod" + LOG_LEVEL: "INFO" + CORS_ORIGINS: "https://bi.example.com" + REQUIRE_FRONTEND_AUTH: "true" + FRONTEND_JWT_ISSUER_URL: "https://idp.example.com/realms/bi" + FRONTEND_JWT_JWKS_URL: "https://idp.example.com/realms/bi/protocol/openid-connect/certs" + FRONTEND_JWT_AUDIENCE: "otel-bi-api" + FRONTEND_JWT_ALGORITHM: "RS256" + FRONTEND_REQUIRED_SCOPES: "openid profile email" + FRONTEND_CLOCK_SKEW_SECONDS: "30" + INTERNAL_SERVICE_AUTH_ENABLED: "true" + INTERNAL_SERVICE_TOKEN_TTL_SECONDS: "120" + INTERNAL_SERVICE_TOKEN_AUDIENCE: "bi-internal" + INTERNAL_SERVICE_ALLOWED_ISSUERS: "api-gateway" + INTERNAL_TOKEN_CLOCK_SKEW_SECONDS: "15" + QUERY_SERVICE_URL: "http://bi-query.bi-platform.svc.cluster.local:8000" + ANALYTICS_SERVICE_URL: "http://analytics.bi-platform.svc.cluster.local:8000" + PERSISTENCE_SERVICE_URL: "http://persistence.bi-platform.svc.cluster.local:8000" + OTEL_COLLECTOR_ENDPOINT: "http://alloy.monitoring.svc.cluster.local:4318" +--- +apiVersion: v1 +kind: Secret +metadata: + name: bi-platform-secrets + namespace: bi-platform +type: Opaque +stringData: + MSSQL_HOST: "mssql.dw.svc.cluster.local" + MSSQL_PORT: "1433" + MSSQL_USERNAME: "readonly_user" + MSSQL_PASSWORD: "readonly_password" + POSTGRES_HOST: "postgres.app.svc.cluster.local" + POSTGRES_PORT: "5432" + POSTGRES_DATABASE: "otel_bi_app" + POSTGRES_USERNAME: "otel_bi_app" + POSTGRES_PASSWORD: "otel_bi_app" + POSTGRES_REQUIRED: "true" + INTERNAL_SERVICE_SHARED_SECRET: "replace-with-strong-random-secret-min-32-bytes" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-gateway + namespace: bi-platform +spec: + replicas: 2 + selector: + matchLabels: + app: api-gateway + template: + metadata: + labels: + app: api-gateway + spec: + automountServiceAccountToken: false + containers: + - name: api-gateway + image: ghcr.io/your-org/otel-bi-backend:latest + imagePullPolicy: IfNotPresent + command: + [ + "uvicorn", + "microservices.api_gateway.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + envFrom: + - configMapRef: + name: bi-platform-config + - secretRef: + name: bi-platform-secrets + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: api-gateway + namespace: bi-platform +spec: + selector: + app: api-gateway + ports: + - port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bi-query + namespace: bi-platform +spec: + replicas: 2 + selector: + matchLabels: + app: bi-query + template: + metadata: + labels: + app: bi-query + spec: + automountServiceAccountToken: false + containers: + - name: bi-query + image: ghcr.io/your-org/otel-bi-backend:latest + imagePullPolicy: IfNotPresent + command: + [ + "uvicorn", + "microservices.bi_query.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + envFrom: + - configMapRef: + name: bi-platform-config + - secretRef: + name: bi-platform-secrets + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: bi-query + namespace: bi-platform +spec: + selector: + app: bi-query + ports: + - port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: analytics + namespace: bi-platform +spec: + replicas: 2 + selector: + matchLabels: + app: analytics + template: + metadata: + labels: + app: analytics + spec: + automountServiceAccountToken: false + containers: + - name: analytics + image: ghcr.io/your-org/otel-bi-backend:latest + imagePullPolicy: IfNotPresent + command: + [ + "uvicorn", + "microservices.analytics.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + envFrom: + - configMapRef: + name: bi-platform-config + - secretRef: + name: bi-platform-secrets + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: analytics + namespace: bi-platform +spec: + selector: + app: analytics + ports: + - port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: persistence + namespace: bi-platform +spec: + replicas: 2 + selector: + matchLabels: + app: persistence + template: + metadata: + labels: + app: persistence + spec: + automountServiceAccountToken: false + containers: + - name: persistence + image: ghcr.io/your-org/otel-bi-backend:latest + imagePullPolicy: IfNotPresent + command: + [ + "uvicorn", + "microservices.persistence.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + envFrom: + - configMapRef: + name: bi-platform-config + - secretRef: + name: bi-platform-secrets + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: persistence + namespace: bi-platform +spec: + selector: + app: persistence + ports: + - port: 8000 + targetPort: 8000