diff --git a/.dockerignore b/.dockerignore index 509d0190..cd975667 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,5 @@ compose.test-services.yml *.duckdb *.sqlite *.log +frontend/node_modules +frontend/dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7616c566..1f92d71e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '22' + node-version: '24' cache: 'npm' cache-dependency-path: frontend/package-lock.json @@ -393,10 +393,22 @@ jobs: cache-from: type=gha,scope=runtime-smoke cache-to: type=gha,scope=runtime-smoke,mode=max + - name: The image refuses to start without authentication + if: matrix.name == 'runtime' + run: | + # No secret and no SENSAPP_AUTH_DISABLED: the image listens on 0.0.0.0, which is not safe open + if docker run --rm sensapp-ci:runtime > refusal.log 2>&1; then + echo "The image started without authentication" + exit 1 + fi + cat refusal.log + grep -q SENSAPP_JWT_SECRET refusal.log + - name: Start runtime image against ClickHouse if: matrix.name == 'runtime' run: >- docker run --detach --name sensapp-ci-runtime --network host + --env SENSAPP_AUTH_DISABLED=true --env SENSAPP_STORAGE_CONNECTION_STRING=clickhouse://default:password@127.0.0.1:8123/sensapp_test sensapp-ci:runtime diff --git a/Cargo.lock b/Cargo.lock index 2e7f9818..5f5de9a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4582,6 +4582,7 @@ dependencies = [ "futures", "gcp-bigquery-client", "geo", + "getrandom 0.3.4", "hifitime", "hybridmap", "influxdb-line-protocol", @@ -4602,6 +4603,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2 0.10.9", "simplify-polyline", "sindit-senml", "smallvec 1.16.2", diff --git a/Cargo.toml b/Cargo.toml index 10e0462d..00ab6f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ arrow-array = "60" anyhow = "1.0" async-trait = "0.1" thiserror = "2.0" +sha2 = "0.10" axum = { version = "0.8", features = ["multipart", "macros"] } base64 = "0.22" futures = "0.3" @@ -60,6 +61,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } uuid = { version = "1.18", features = ["serde"] } fastrand = "2" +getrandom = "0.3" csv-async = "1.3" rust_decimal = "1.39" geo = "0.32" diff --git a/Dockerfile b/Dockerfile index 27273f33..56d47bd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +# The web UI is static files: build them once, on the build platform, whatever the target. +FROM --platform=$BUILDPLATFORM node:24-slim AS frontend + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + FROM rust:1.98-slim-bookworm AS chef ARG DUCKDB_DOWNLOAD_LIB="1" @@ -77,8 +88,14 @@ COPY --from=builder /app/target/release/sensapp /usr/local/bin/sensapp COPY --from=builder /out/lib/ /usr/local/lib/ RUN ldconfig +# Served under /ui/, see SENSAPP_UI_ENABLED +COPY --from=frontend /frontend/dist /usr/share/sensapp/ui + +# The image listens on every address, so it does not start without authentication: give it +# SENSAPP_JWT_SECRET (`sensapp generate-secret` makes one), or SENSAPP_AUTH_DISABLED=true. ENV SENSAPP_ENDPOINT=0.0.0.0 \ SENSAPP_PORT=3000 \ + SENSAPP_UI_DIR=/usr/share/sensapp/ui \ SENSAPP_STORAGE_CONNECTION_STRING=sqlite:///var/lib/sensapp/sensapp.db \ RUST_LOG=info diff --git a/README.md b/README.md index b85ba905..e78d2ef2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![SensApp](./docs/sensapp_logo.png) -SensApp is an open-source sensor data platform developed by SINTEF. +SensApp is an open-source sensor data platform developed by [SINTEF](https://www.sintef.no/). It handles time-series data ingestion, storage, and retrieval. From small edge devices to big data digital twins, SensApp *may* be useful. @@ -10,22 +10,23 @@ SensApp is compatible with Prometheus and InfluxDB, but with an alternative arch Dealing with system statistics for the last 24 hours? InfluxDB or Prometheus are excellent choices. Fetching average bathroom temperatures over the last 10 years grouped by day? SensApp will compute that instantly while InfluxDB or Prometheus will take a little while. -But you don't have to chose, both InfluxDB and Prometheus can replicate their data to SensApp for long-term storage and analysis. So you get the best of both worlds. +But you don't have to chose, both InfluxDB and Prometheus can replicate their data to SensApp for long-term storage and analysis. So you can get the best of both worlds. -You can also use SensApp as a standalone time-series database. +You can also use SensApp as a standalone time-series database. It's designed to work from the edge to the big data workloads in the cloud, by relying on different databases. -## Quickstart +![GUI Screenshot](./docs/screenshot.png) -The quickest way to run SensApp is with SQLite so no external database is required. +## Quickstart -Start SensApp with SQLite: +The simplest way to test SensApp is using it with SQLite and no authentication: ```bash -SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db \ -cargo run +export SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db +export SENSAPP_AUTH_DISABLED=true +cargo run --release ``` -By default, SensApp listens on [http://127.0.0.1:3000](http://127.0.0.1:3000). +By default, SensApp listens on [http://127.0.0.1:3000](http://127.0.0.1:3000) for both its API and user interface. Ingest one sample: @@ -59,7 +60,7 @@ async def main(): asyncio.run(main()) ``` -### Using Containers +## Software Containers and Kubernetes ```bash docker compose up @@ -76,6 +77,7 @@ Published releases also provide the chart through GHCR as an OCI package; see th ## Features - **HTTP REST API** +- **Graphical User Interface** - **Prometheus Compatibility** - **Prometheus Remote Write**: Prometheus can push data to SensApp. - **Prometheus Remote Read**: Prometheus can also read data from SensApp. @@ -102,7 +104,7 @@ SensApp's architecture is relatively simple as the complex problems are delegate Most of the complexity lies in the [database schema design](docs/DATAMODEL.md). After that, it's mostly some code glue. -- On the **edge**, SensApp can be deployed as a single lightweight instance with an embedded SQLite database. +- On the **edge**, SensApp can be deployed as a single instance with a SQLite database. - For **medium** deployments, SensApp can be deployed with a PostgreSQL database. - For **larger** deployments, many SensApp instances can be deployed behind a load balancer, connected to a ClickHouse database cluster. A message queue and some middleware can be considered to ingest data at scale. @@ -112,11 +114,13 @@ Check the [ARCHITECTURE.md](docs/ARCHITECTURE.md) file for more details. ## Authentication -SensApp supports **optional JWT authentication**. By default, all endpoints are open. Visit [docs/JWT_AUTH.md](./docs/JWT_AUTH.md) for the authentication documentation. +SensApp uses JSON Web Tokens (JWT) for its authentication. Visit [docs/JWT_AUTH.md](./docs/JWT_AUTH.md) for the detailed authentication documentation. + +While you can start sensapp with authentication disabled, it is recommended to use the authentication feature, in command line or through the web interface. By default, SensApp will print an admin token and a link that you can use to generate more tokens. ## Built With Rust™️ -SensApp is developed using Rust, a language known for its performance, memory safety, and annoying borrow checker. SensApp used to be written in Scala, but the new author prefers Rust. +SensApp is developed using Rust, a language known for its performance, memory safety, and annoying borrow checker. SensApp used to be written in Scala, but the new authors prefers Rust. Another reason is from the results from the paper [Energy efficiency across programming languages: how do energy, time, and memory relate?](https://dl.acm.org/doi/10.1145/3136014.3136031), which shows Rust as one of the most energy-efficient programming languages while having memory safety. @@ -136,7 +140,7 @@ The SensApp software is provided "as is," with no warranties, and the creators o ## You May Not Want to Use It in Production (Yet) -SensApp is currently under development. It is not ready for production. +SensApp is currently under development. It is getting ready for production. ## Acknowledgements diff --git a/TODO.md b/TODO.md index 5bd95c12..f3a02d9f 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ This file tracks the main remaining work for SensApp. Detailed task history lives in `current_tasks/`, `ideas/` and `done/`. -The core is in place: HTTP-only ingestion, DCAT/query/export endpoints, Prometheus and InfluxDB compatibility, health endpoints, Prometheus service metrics, JWT sensor authorization, HTTP resource limits, data lifecycle controls, cross-series aggregation, a Python SDK, Helm chart and container images, and a broad integration test suite run against every backend in CI. +The core is in place: HTTP-only ingestion, DCAT/query/export endpoints, Prometheus and InfluxDB compatibility, health endpoints, Prometheus service metrics, secure-by-default JWT authentication (sensor authorization, an admin scope, secret rotation, a Credentials tab), HTTP resource limits, data lifecycle controls, cross-series aggregation, a Python SDK, Helm chart and container images, and a broad integration test suite run against every backend in CI. The next phase is not to add more features. It is to make the existing system solid enough for pre-production, with ClickHouse as the reference backend. The acceptance criteria and sequencing are in [docs/PREPRODUCTION_RELEASE_PLAN.md](docs/PREPRODUCTION_RELEASE_PLAN.md). diff --git a/charts/sensapp/README.md b/charts/sensapp/README.md index 5ac61ca3..fb91c538 100644 --- a/charts/sensapp/README.md +++ b/charts/sensapp/README.md @@ -52,3 +52,20 @@ Supported URL forms in the default container image include: | ClickHouse HTTPS | `clickhouses://user:password@clickhouse.example.com:8443/sensapp` | For a quick setup, you can set `storage.connectionString` directly instead of referencing a Secret. Helm stores that value in the release configuration, so use an existing Secret for credentials. External databases own their storage and lifecycle; leave `persistence.enabled=false` for these backends. + +## Authentication + +SensApp does not run open: the container image listens on every address, so it refuses to start without a secret. By default the chart **makes a random secret on the first install** and keeps it across upgrades (it reads it back from the Secret of the release). Make an admin token, which makes the other tokens in the UI (Credentials tab): + +```bash +kubectl exec deploy/sensapp -- sensapp generate-token me --scope read,admin +``` + +| Value | Effect | +| --- | --- | +| `auth.jwtSecret` | A secret of your choice (at least 32 characters). Stored in the Secret of the release and in the release values: prefer `existingSecret`. | +| `auth.existingSecret`, `auth.existingSecretKey` | A Secret of yours holding `SENSAPP_JWT_SECRET`. Takes precedence over `jwtSecret`. | +| `auth.previousSecrets` | Comma-separated secrets that still verify tokens but never sign: the way to rotate the secret, see [JWT_AUTH.md](../../docs/JWT_AUTH.md#rotating-the-secret-and-revoking-tokens). | +| `auth.disabled` | Every endpoint is open (`SENSAPP_AUTH_DISABLED`), for a network that authenticates in front of SensApp. Ignored when a secret is given. | + +The made secret needs the chart to read the cluster (`lookup`). Tools that only render the chart (`helm template`, Argo CD) cannot, and would make a new secret at each render: for those, set `auth.jwtSecret` or `auth.existingSecret`. diff --git a/charts/sensapp/templates/NOTES.txt b/charts/sensapp/templates/NOTES.txt index b7a927de..c2e2a3d6 100644 --- a/charts/sensapp/templates/NOTES.txt +++ b/charts/sensapp/templates/NOTES.txt @@ -14,13 +14,22 @@ echo http://127.0.0.1:3000 {{- end }} -{{- if .Values.auth.jwtSecret }} -2. JWT authentication is ENABLED. - Generate a token with: - sensapp generate-token --scope readwrite --duration 3600 +{{- if and .Values.auth.disabled (not .Values.auth.jwtSecret) (not .Values.auth.existingSecret) }} +2. Authentication is DISABLED (auth.disabled): every endpoint is open to whoever can reach it. + To require tokens, remove auth.disabled. +{{- else }} +2. Authentication is ENABLED: the endpoints need a token. +{{- if .Values.auth.existingSecret }} + The secret is the key {{ .Values.auth.existingSecretKey }} of the Secret {{ .Values.auth.existingSecret }}. +{{- else if .Values.auth.jwtSecret }} + The secret is auth.jwtSecret. +{{- else }} + The chart made the secret, and keeps it across upgrades. +{{- end }} + Make an admin token (it makes the other tokens, in the UI under Credentials or with POST /api/v1/admin/tokens): + kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token me --scope read,admin + Or a token for a client: + kubectl exec --namespace {{ .Release.Namespace }} deploy/{{ include "sensapp.fullname" . }} -- sensapp generate-token --scope readwrite --duration 86400 Use it in requests: curl -H "Authorization: Bearer " http:///metrics -{{- else }} -2. JWT authentication is DISABLED. All endpoints are open. - To enable, set auth.jwtSecret to a random string (≥ 32 chars). {{- end }} diff --git a/charts/sensapp/templates/_helpers.tpl b/charts/sensapp/templates/_helpers.tpl index 06dca6a9..cc7db66e 100644 --- a/charts/sensapp/templates/_helpers.tpl +++ b/charts/sensapp/templates/_helpers.tpl @@ -39,3 +39,35 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- default "default" .Values.serviceAccount.name -}} {{- end -}} {{- end -}} + +{{/* +"true" when the chart makes the JWT secret itself: authentication is on, and no secret was given +(neither `auth.jwtSecret` nor `auth.existingSecret`). Empty otherwise. +*/}} +{{- define "sensapp.generateJwtSecret" -}} +{{- if and (not .Values.auth.disabled) (not .Values.auth.jwtSecret) (not .Values.auth.existingSecret) -}} +true +{{- end -}} +{{- end -}} + +{{/* +The JWT secret the chart makes: the one of the Secret of the release when it exists, so that an +upgrade keeps it (and the tokens made with it), a new random one on the first install. `helm +template` and the tools that only render (Argo CD) cannot look the Secret up: they would make +a new secret at each render, so give `auth.jwtSecret` or `auth.existingSecret` there. +*/}} +{{- define "sensapp.jwtSecret" -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "sensapp.fullname" .) -}} +{{- if and $existing $existing.data (hasKey $existing.data "SENSAPP_JWT_SECRET") -}} +{{- index $existing.data "SENSAPP_JWT_SECRET" | b64dec -}} +{{- else -}} +{{- randAlphaNum 48 -}} +{{- end -}} +{{- end -}} + +{{/* "true" when the Secret of the release holds something */}} +{{- define "sensapp.hasSecret" -}} +{{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.auth.previousSecrets .Values.secretEnv (include "sensapp.generateJwtSecret" .) -}} +true +{{- end -}} +{{- end -}} diff --git a/charts/sensapp/templates/configmap.yaml b/charts/sensapp/templates/configmap.yaml index 91825052..18c600eb 100644 --- a/charts/sensapp/templates/configmap.yaml +++ b/charts/sensapp/templates/configmap.yaml @@ -6,6 +6,9 @@ metadata: {{- include "sensapp.labels" . | nindent 4 }} data: SENSAPP_PORT: {{ .Values.service.port | quote }} + {{- if .Values.auth.disabled }} + SENSAPP_AUTH_DISABLED: "true" + {{- end }} {{- range $key, $value := .Values.env }} {{- if ne $key "SENSAPP_PORT" }} {{ $key }}: {{ $value | quote }} diff --git a/charts/sensapp/templates/deployment.yaml b/charts/sensapp/templates/deployment.yaml index 4583d5ab..9d3282ff 100644 --- a/charts/sensapp/templates/deployment.yaml +++ b/charts/sensapp/templates/deployment.yaml @@ -43,14 +43,14 @@ spec: envFrom: - configMapRef: name: {{ include "sensapp.fullname" . }} - {{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.secretEnv }} + {{- if include "sensapp.hasSecret" . }} - secretRef: name: {{ include "sensapp.fullname" . }} {{- end }} {{- with .Values.extraEnvFrom }} {{- toYaml . | nindent 12 }} {{- end }} - {{- if or .Values.storage.existingSecret .Values.extraEnv }} + {{- if or .Values.storage.existingSecret .Values.auth.existingSecret .Values.extraEnv }} env: {{- if .Values.storage.existingSecret }} - name: SENSAPP_STORAGE_CONNECTION_STRING @@ -59,6 +59,13 @@ spec: name: {{ .Values.storage.existingSecret | quote }} key: {{ .Values.storage.existingSecretKey | quote }} {{- end }} + {{- if .Values.auth.existingSecret }} + - name: SENSAPP_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.auth.existingSecret | quote }} + key: {{ .Values.auth.existingSecretKey | quote }} + {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/sensapp/templates/secret.yaml b/charts/sensapp/templates/secret.yaml index df4606df..7de7554c 100644 --- a/charts/sensapp/templates/secret.yaml +++ b/charts/sensapp/templates/secret.yaml @@ -1,4 +1,4 @@ -{{- if or (not .Values.storage.existingSecret) .Values.auth.jwtSecret .Values.secretEnv }} +{{- if include "sensapp.hasSecret" . }} apiVersion: v1 kind: Secret metadata: @@ -12,6 +12,11 @@ stringData: {{- end }} {{- if .Values.auth.jwtSecret }} SENSAPP_JWT_SECRET: {{ .Values.auth.jwtSecret | quote }} + {{- else if include "sensapp.generateJwtSecret" . }} + SENSAPP_JWT_SECRET: {{ include "sensapp.jwtSecret" . | quote }} + {{- end }} + {{- if .Values.auth.previousSecrets }} + SENSAPP_JWT_PREVIOUS_SECRETS: {{ .Values.auth.previousSecrets | quote }} {{- end }} {{- range $key, $value := .Values.secretEnv }} {{ $key }}: {{ $value | quote }} diff --git a/charts/sensapp/values.yaml b/charts/sensapp/values.yaml index ae4cd1e0..0b17158e 100644 --- a/charts/sensapp/values.yaml +++ b/charts/sensapp/values.yaml @@ -86,14 +86,27 @@ storage: existingSecret: "" existingSecretKey: SENSAPP_STORAGE_CONNECTION_STRING -# Optional JWT authentication. -# When jwtSecret is set, read/write endpoints require a valid Bearer token. -# Health checks, docs, and prometheus scrape remain open. -# Use `sensapp generate-token ` to create tokens. +# Authentication. SensApp does not run open unless told to: the protected endpoints need a JWT +# signed with a secret. Health checks, docs, the UI files and the Prometheus scrape stay open. +# +# By default the chart makes a random secret on the first install and keeps it across upgrades +# (it is read back from the Secret of the release). Make an admin token with: +# kubectl exec deploy/ -- sensapp generate-token me --scope read,admin +# Tools that only render the chart (Argo CD, `helm template`) cannot read it back and would make a +# new secret each time: for those, give jwtSecret or existingSecret. auth: - # Set to a random string of at least 32 characters to enable JWT auth. - # Leave empty (or omit) to run without authentication (default). + # A secret of at least 32 characters (`sensapp generate-secret` makes one). Stored in the Secret + # of the release, and in the release values: prefer existingSecret. jwtSecret: "" + # A Secret of yours holding the key below. Takes precedence over jwtSecret. + existingSecret: "" + existingSecretKey: SENSAPP_JWT_SECRET + # Secrets that still verify tokens but never sign them, separated by commas: the way to rotate + # the secret. Dropping one revokes the tokens it signed. + previousSecrets: "" + # Run with every endpoint open (SENSAPP_AUTH_DISABLED). For a network that authenticates + # in front of SensApp. Ignored when a secret is given. + disabled: false secretEnv: {} extraEnv: [] diff --git a/compose.prometheus-roleplay.yml b/compose.prometheus-roleplay.yml index 49117fdd..31c7472a 100644 --- a/compose.prometheus-roleplay.yml +++ b/compose.prometheus-roleplay.yml @@ -8,6 +8,8 @@ services: container_name: sensapp-roleplay environment: SENSAPP_ENDPOINT: 0.0.0.0 + # A demo on a laptop, with clients that send no token + SENSAPP_AUTH_DISABLED: "true" SENSAPP_PORT: 3017 SENSAPP_STORAGE_CONNECTION_STRING: duckdb:///workspaces/sensapp/python/roleplay-datascience/roleplay-session.duckdb RUST_LOG: info diff --git a/docs/CI.md b/docs/CI.md index 2939b589..7c915da8 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -6,7 +6,8 @@ tags, published releases, and manual dispatch. ## What is checked - Rust formatting, a blocking `cargo audit` (with the documented RSA advisory - exception in `Makefile.toml`), frontend lint/typecheck/tests/build and a + exception in `Makefile.toml`), frontend lint/typecheck/tests/build (a Rust test + also checks that `frontend/openapi.json` is the OpenAPI document of the server) and a high-severity npm audit, and Python 3.14 SDK unit tests, lint, package build and wheel install smoke test. @@ -23,7 +24,8 @@ see `ideas/remove-frontend-js-yaml-override.md` for the override follow-up. ([BIGQUERY.md](BIGQUERY.md)). It does not block the ClickHouse release path. - Helm lint/template/package and Docker builds. The normal runtime image is actually started with ClickHouse, then `tests/clickhouse_container_smoke.py` - checks readiness, publish, query, and service metrics. Live Python SDK tests + checks readiness, publish, query, service metrics, and that the web UI is served + (the image builds it in its own Node stage). Live Python SDK tests also run against this image. CI stops ClickHouse and verifies that readiness becomes unhealthy, restarts it, and verifies recovery. The image smoke job is skipped on published releases: the tagged commit already passed it on `main`, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0cdfb875..7c4acd10 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -40,11 +40,23 @@ The Helm chart sets variables through `env:` in `values.yaml` (see the [chart RE Details on what the limits protect against are in [HTTP_LIMITS.md](HTTP_LIMITS.md). +### Web UI + +| Variable | Default | Description | +| --- | --- | --- | +| `SENSAPP_UI_ENABLED` | `true` | Serves the web UI under `/ui/` and redirects `/` to it. `false` brings back the JSON name of the instance at `/`. | +| `SENSAPP_UI_DIR` | `frontend/dist` | Directory with the built UI. The container image sets it to `/usr/share/sensapp/ui`. When it has no `index.html` (the frontend was not built) SensApp logs a warning and does not serve the UI: it still starts. | + +See [FRONTEND.md](FRONTEND.md). + ### Authentication | Variable | Default | Description | | --- | --- | --- | -| `SENSAPP_JWT_SECRET` | unset | Enables JWT authentication when set (at least 32 characters). Unset means every endpoint is open. See [JWT_AUTH.md](JWT_AUTH.md). | +| `SENSAPP_JWT_SECRET` | unset | Secret that signs and verifies the JWTs (at least 32 characters, `sensapp generate-secret` makes one). When unset, SensApp does not run open: on a loopback address it makes a secret for the run and prints an admin token, on any other address (the container image) it refuses to start, unless `SENSAPP_AUTH_DISABLED` is true. See [JWT_AUTH.md](JWT_AUTH.md). | +| `SENSAPP_AUTH_DISABLED` | `false` | Run with every endpoint open: the explicit opt-out for demos, tests, and networks that authenticate in front of SensApp. Ignored when `SENSAPP_JWT_SECRET` is set. | +| `SENSAPP_JWT_PREVIOUS_SECRETS` | unset | Comma-separated secrets that still verify tokens but never sign them: the way to rotate the secret, or revoke the tokens of one. See [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens). | +| `SENSAPP_TOKEN_MAX_DURATION_SECONDS` | `31536000` | Longest validity of a token made with `POST /api/v1/admin/tokens` (a year). The command line is not capped by it. | ### Data diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md index 726c6b60..5b275fb5 100644 --- a/docs/DATAMODEL.md +++ b/docs/DATAMODEL.md @@ -209,3 +209,18 @@ In the current version, the geolocalised data doesn't really mind the coordinate ## TimeStamps are in microseconds We use microsecond timestamps, as it provides a good compromise between precision and storage size. Some time-series database go down to nanoseconds but then the minimum and maximum timestamps are too close in times using 64 bits integers. It should be possible to have historical data and prediction data in SensApp. We haven't identified use cases that would require nanosecond precision in our research. People with such use cases should consider patching SensApp or using another solution. + +## Prometheus remote read and its hints + +A remote read query carries the hints of the PromQL query that asks for it: the function (`avg_over_time`), the `step` between two evaluations and the `range` of the `[1h]`. Prometheus does not trust the answer to have applied them: it evaluates the query again on the samples it receives. SensApp therefore only aggregates in the database (one bucket per `step`) when this second evaluation gives the result of the raw samples, and sends the raw samples otherwise, within the selector limits of [HTTP_LIMITS.md](HTTP_LIMITS.md). + +| Hint | Answered with | +| --- | --- | +| `avg_over_time`, `min_over_time`, `max_over_time`, `sum_over_time`, `first_over_time`, `last_over_time`, with a `range` that is a whole number of steps | buckets of one `step`, which start where the window of the first evaluation starts | +| `count_over_time` | raw samples (Prometheus would count the buckets) | +| a `range` shorter than the `step`, or not a whole number of steps | raw samples (the bucket would be wider than the window) | +| `sum`, `avg`, `min`, `max`, `count` across series, `rate`, plain selectors | raw samples (the value at the evaluation time is needed, not an aggregate of the following step) | + +When the range is several steps, `min`, `max`, `sum`, `first` and `last` are exact. `avg_over_time` averages the averages of the buckets of its window: exact when they hold as many samples each, an approximation otherwise (a series with gaps, the edges of the data). This is a compromise, the price of sending a year of data at a one hour step as 8,760 values instead of all the samples. + +This was measured with real Prometheus 3.8, 3.13 (LTS) and 3.15 (`tests/prometheus_live`). Prometheus 3 evaluates the window `(t - range, t]` and asks from `t - range + 1 ms`; Prometheus 2 is not supported. diff --git a/docs/DATA_LIFECYCLE.md b/docs/DATA_LIFECYCLE.md index 198697af..63925267 100644 --- a/docs/DATA_LIFECYCLE.md +++ b/docs/DATA_LIFECYCLE.md @@ -80,7 +80,7 @@ No action is needed. ## Authorization -With [JWT authentication](JWT_AUTH.md) enabled, deleting requires the `delete` scope. +With [authentication](JWT_AUTH.md) on, deleting requires the `delete` scope. The default `read write` scope does **not** include it, so a leaked edge-device token cannot erase history. ```bash @@ -92,7 +92,7 @@ A `delete` token is still limited by its `sensors` allow list. Series it cannot as `404`, like on the read endpoints. The `delete` scope does not allow reading: combine it with `read` to be able to list series and check what was removed. -Without JWT authentication, like every other endpoint, deletes are open. +With authentication disabled (`SENSAPP_AUTH_DISABLED`), like every other endpoint, deletes are open. Each deletion is logged at `INFO` level with the series UUID, the token subject, and the number of samples. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 00000000..2ed085ff --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,129 @@ +# Web UI + +SensApp ships a small explorer: pick a metric, pick series, draw them over a time range. A second tab, **Load Data**, explains how to get data in, and a third, **Credentials**, makes tokens. It is a React single page application in [`frontend/`](../frontend), built to static files that SensApp serves itself. There is no separate container. + +## Serving + +- The files are served under `/ui/`, and `/` redirects there. The container image carries them (the Dockerfile builds them in its own Node stage); with `cargo run`, build them first with `npm run build` in `frontend/`. +- `SENSAPP_UI_ENABLED=false` turns it off, `SENSAPP_UI_DIR` says where the files are. See [CONFIGURATION.md](CONFIGURATION.md#web-ui). A missing build is a warning at startup, never a failure. +- The files are public. Responses carry a `Content-Security-Policy` (the page only talks to its own origin, and cannot be framed), `X-Content-Type-Options: nosniff` and `Cache-Control: no-cache`. +- The UI calls the SensApp API of its own origin: `/metrics`, `/series`, `/series/{uuid}` and `/health/ready`. + +## Charts + +Two selectors next to the time range control what the server computes, as in the Influx and Prometheus UIs: + +- **Step**: `Auto`, `Raw`, or a fixed duration (`5s` … `1d`). `Auto` reads ranges under about 2.8 hours as they are, and above that cuts the range in at most 2 000 buckets of a round duration, because the server refuses more than 100 000 raw samples of a series ([HTTP_LIMITS.md](HTTP_LIMITS.md)). `Raw` on a range that is too wide gets that refusal. +- **Aggregation**: `avg`, `min`, `max`, `sum`, `count`, `first`, `last`, applied to the samples of each step. Disabled on `Raw`. + +Series are drawn as a `line`, `step`, `area`, `stacked` or `bars` chart, on a linear or `log` scale. The chart has no legend of its own: the list of series shows the color of each selected series, which it keeps for as long as it is selected. A series that is selected or unselected comes and goes with an animation, the others stay. Hovering a row of the list makes its series stand out in the chart. + +### The time window + +There is one time window, the one of the explorer, and the chart draws exactly that. The chart has no zoom of its own (a zoom on points that were cut for another window shows the same coarse points bigger): + +- **Drag on the chart** to choose a window: it is asked of the server again, at the step that fits it (raw samples when zoomed in far enough). The old points stay, on the new axis, until the new ones arrive; a thin bar over the chart says it is loading. +- **In the header of the chart**, with the style and the scale: `↶` goes back to the window before (the last 20 windows the user left; what the clock does to a preset is not one), `‹` and `›` move by half a window (`›` never goes past now, and is off on a live preset), `−` shows twice the window (a live preset goes to the next one), then the presets, the dates, the step and the aggregation. +- A preset window is **live**: its end is now, and moves every minute while the tab is on screen. A window that was dragged, moved or typed is not. + +### Colors + +The first 8 series have the eight hues of the dataviz palette, validated for color-blind readers (neighbours are 8.4 or more apart in OKLab ×100 under protan and deutan vision, 19 or more with normal vision). The 16 colors after them are there so that no series repeats a color: each was chosen as the farthest, under normal and color-blind vision, from the ones before it. They are not equally good: the 9th to 11th are 10 or more from the others, from the 12th it goes down to 5 or 6. Past about 8 series the colors do not say which line is which, which is why hovering a row of the list highlights its line. The 25th series and the following ones take the colors again. A selected series keeps its color while it is selected. The colors are in `src/lib/palette.ts`, for the light and the dark theme. Booleans are drawn as a 0/1 step line on an axis of their own, whatever the style, and always read as they are (the server cannot aggregate them). Strings and locations are listed but not drawn. + +## Code + +The **Code** button of the header opens the code that loads what the explorer shows, in two tabs: the [Python SDK](../python/sensapp/README.md) (`get_series`, which gives a Polars DataFrame) and `curl` (one request for each series, as CSV). A button copies it. + +- It is made from the state of the explorer: the selected series (their uuid, named in a comment: what every series has is said once, as in the list), the time window, the step and the aggregation. A series the step cannot average (booleans, strings) is read as it is, as in the chart. +- A preset window is **relative to now** in Python (`datetime.now(UTC) - timedelta(hours=24)`), so the script can be run again later; a window of dates, and every curl request, have the dates written out. +- The server is the origin of the page. When the server asked for a token, or one is in use, the code reads it from `SENSAPP_TOKEN` (`os.environ["SENSAPP_TOKEN"]`, `$SENSAPP_TOKEN`) and says how to make one. **The token of the session is never written in the code**: a snippet is meant to be pasted, saved and shared. +- **A selector** typed in the box of the series list (`{host="a"}`, with the metric) is what the code asks for, instead of the uuids: `list_series(metric=…, selector=…)` then `get_series` of each in Python, and `curl -G --data-urlencode` into `jq` and a loop in the shell (`jq` is needed). The code then reads every series that matches, whenever it runs, which is not always what is checked: the checkbox above the code goes back to the selected series. The selector is forgotten when another metric is chosen. As the series are not known when the code is written, a step is only asked for the numbers among them (Python looks at `sensor_type`, which the server writes `Float`; the shell loop leaves the others out and says so). A selector that matches more than a page (256 series) reads the first page. +- With no series selected, and no selector, it lists the series of the metric, and with no metric the metrics. +- Whatever a label or a name says is quoted (Python and shell) or put on one line (comments): a hostile label cannot add a line of code to what is copied. +- The code is always on a dark ground, in both themes, in JetBrains Mono, coloured by highlight.js (Python and Bash only). Long lines wrap instead of scrolling sideways (a uuid or an address with no space breaks where it must); the copied text is unchanged. The dialog, the highlighter and the font are loaded when it is first opened: 13 kB gzipped and 40 kB of font, nothing for a page that never opens it. The copy works on a page served by plain http too, where the clipboard API does not exist. +- The Python SDK is not on PyPI yet. The script starts with a [PEP 723](https://peps.python.org/pep-0723/) block (`# /// script`) that says it needs `sensapp` and that it comes from GitHub (`[tool.uv.sources.sensapp]`), so `uv run script.py` is enough: uv makes the environment. The `uv pip install` command is there too, for an environment of one's own. Both are in `src/lib/snippets.ts` (`SCRIPT_METADATA`, `INSTALL_COMMENT`): change them when the SDK is published, and the Python the block asks for follows `requires-python` of the SDK. + +## Load Data + +The second tab of the header (`/ui/load`) explains how to get data into SensApp, with code to copy (the same dark block and copy button as the Code dialog). It is short on purpose, enough to start; `/docs` is the reference. No card, no title (the tab is the title): the page uses the whole width and scrolls by itself (the explorer fills the window, this page does not), with underlined tabs like the header's, and a section is a title and a sentence on the left, its code on the right (stacked on a narrow screen). The way is a tab of the page and is in the address (`/ui/load?via=telegraf`; `python`, `telegraf`, `prometheus`, `curl`; Python when it is missing or unknown). + +- **Python SDK**: a whole DataFrame (sent by slices of 100 000 samples, as [PYTHON_SDK.md](PYTHON_SDK.md#timeouts) advises), one sample at a time, a few samples at a time. Same `uv run` header as the Code dialog. +- **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write` with a few inputs (see [INFLUX_DB.md](INFLUX_DB.md)), and how to check it (`--once`). +- **Prometheus**: `remote_write` and `remote_read` (`read_recent: true`), and the address to use from a container. +- **curl**: SenML JSON, CSV and InfluxDB line protocol, to `/publish` and `/api/v2/write`. +- The server is the origin of the page. When it asked for a token (or one is in use) every way starts with the same **A token** section: where the code finds it (`SENSAPP_TOKEN`), the command that makes a `write` one (`sensapp generate-token me --scope write`) and a link to the [Credentials](#credentials) tab; **no token is ever written in the code**. Prometheus reads its token from a file, so its section gives the command that writes it (a year long, for reading and writing). With an open server there is no such section. +- **Telegraf and tokens.** Telegraf sends its `token` as `Authorization: Token …`, the InfluxDB way, and SensApp reads that scheme as well as `Bearer …`. The snippet gives Telegraf its own `token = "${SENSAPP_TOKEN}"`, no header to rewrite. Checked against a server with a secret. +- The snippets are in `src/lib/loadSnippets.ts` (one function for each way, tested), the page in `src/pages/LoadPage.tsx`. The page is loaded when its tab is first opened (5 kB gzipped, the highlighter is shared with the Code dialog). +- All of them were run against a real SensApp (PostgreSQL), with and without a JWT secret (the Telegraf one before SensApp read the `Token` scheme, with a `Bearer` header): the Python scripts, the curl commands, Telegraf (`--once`), and the Prometheus configuration with `promtool check config`. + +## Credentials + +The third tab (`/ui/credentials`) makes tokens for the clients of SensApp (see [JWT_AUTH.md](JWT_AUTH.md)). It has the layout of Load Data: a title and a sentence on the left, the choice on the right, the whole width, no card. The choices, by weight: + +1. **Name**: who or what the token is for. It is the `sub` of the token, so it is in the logs of every request the token makes; it grants nothing. +2. **Valid for**: 1 hour, 1 day (the default), 30 days, 1 year. A token cannot be revoked, so this is the main safeguard. +3. **What it may do**: `read` and `write` by default, `delete` on demand, and `admin`, which only the command line makes. +4. **Only these sensors**: type a name and press Enter (or Add), remove it with its ×. A name is kept exactly as typed, spaces and commas included, and the sensors of the server are offered to click when the token in use can read them (an admin-only token cannot). +5. **Command line**: the `sensapp generate-token …` command for what the form says, updated as it changes, with a `--sensor` for each name and everything quoted for the shell. It works without an admin token, needs the secret (so it runs where SensApp runs: `docker exec`, `kubectl exec`), and is the only way to make an admin token. +6. **Make it here**: the button, for an admin token (`POST /api/v1/admin/tokens`). Without one the section says how to make one and opens the sign-in dialog, which then suggests the admin command (`sensapp generate-token me --scope read,admin`: `read` too, so that the same token browses the data and the explorer does not ask for another) instead of a read token. + +The token is then **shown once** at the top of the page, with a Copy button, what it was made with and the `export SENSAPP_TOKEN=…` line that [Load Data](#load-data) reads. SensApp keeps nothing: the page has no list of tokens and cannot revoke one (rotating the secret does, see [JWT_AUTH.md](JWT_AUTH.md#rotating-the-secret-and-revoking-tokens)). The page does not keep the token either: it is dropped from the cache of the requests when the page is left, and a refresh forgets it. + +With no token, a server that answers anyway has authentication disabled: the page says so and shows nothing else. The catalog (for the sensors to click in) is only read when the token in use can read: an `admin` token alone cannot, and the refusal would ask for a token again. + +The page is in `src/pages/CredentialsPage.tsx` (the command and the checks of a name in `src/lib/credentials.ts`) and loaded when its tab is first opened (it shows code, with the highlighter). + +## Address + +The explorer is in the address, so a link shares a view and a reload keeps it: `/ui/?metric=cpu&series=&series=&range=24h&step=5m&agg=max&style=area&log=1`. + +| Parameter | Meaning | Left out when | +| --- | --- | --- | +| `metric` | the selected metric | none | +| `series` | a selected series, repeated | none | +| `range` | a preset (`15m`, `1h`, `6h`, `24h`, `7d`, `30d`), ending now | `1h` | +| `from`, `to` | an absolute range (ISO 8601), once dates were typed | a preset is used | +| `step`, `agg` | the step (`raw` or a duration) and the aggregation | `auto`, `avg` | +| `style`, `log` | the chart style, `log=1` for the log scale | `line`, linear | + +The address follows the explorer without adding history entries. What is wrong in an address is ignored; a series that no longer exists is dropped. A series is named by its uuid only: the UI asks the server for the first sample of each one (its name, labels and type), and on a server with authentication a link asks for a token like any other page. + +A preset range ends now: it moves on every minute while the tab is on screen, and when the tab comes back. Dates that were typed do not move. + +The metrics have a radio button, as the series have a checkbox, and when the page opens on a metric the list starts there. The filters of both lists are in the row of their title, with the count (and the pager of the series). + +The list of series has a column for each label dimension, sorted by the first one (numbers inside the values as numbers: `node-2` before `node-10`; a click on a header sorts by it, again reverses it; the server pages by creation order so the sort is of the page on screen), and the uuid in small. A label that every series has the same is said once above the list instead of a column: the InfluxDB endpoint adds `influxdb_org` and `influxdb_bucket` on purpose, they are part of the identity of a series. The selector box suggests a selector made of labels of the first series. The checkbox of a selected series has the color of its line, and the one of the header selects what is on screen. The Type column is there only when the series of a name are not all of one type: the server groups the metrics by name and type, so a name that exists as a float and as a string is two rows of the metrics list and one list of series. Choosing a metric with at most 8 series (the size of the palette) selects them all, the ones that can be drawn (numbers and booleans); with more, the choice is the user's. + +The list of series is paged by the server (256 per page, cursor based): Previous and Next appear when there is more than one page, and the selection is kept from page to page. The theme follows the OS (light or dark), the chart included. + +## Authentication + +SensApp is not open by default (see [JWT_AUTH.md](JWT_AUTH.md)). When authentication is on, the API answers `401` and the UI shows a dialog asking for a token: paste the output of `sensapp generate-token --scope read`. With authentication disabled (`SENSAPP_AUTH_DISABLED`) the UI works without further ado. + +- **A local SensApp without a secret prints a link** (`http://127.0.0.1:3000/ui/#token=…`) that opens the UI signed in. The token is in the *fragment* of the address, which the browser never sends to the server, so it is not logged. The UI takes it out of the address as soon as the page loads (`src/lib/tokenFromAddress.ts`), so that it stays out of the history and out of a copied link. +- The token is kept in `sessionStorage`: it is gone when the tab closes and never shared with other tabs. +- An expired or invalid token (`401`), or a token without the `read` scope (`403`), shows the dialog again with the answer of the server. So does a refused request to make a token. +- "Sign out" in the header forgets the token. +- A token with a sensor allow list shows only its sensors, as with any client. + +SensApp has no login endpoint: it does not know users, only signed tokens. + +## Development + +```bash +cd frontend +npm ci +npm run dev # http://localhost:5173/ui/, proxies the API to http://localhost:3000 +npm run lint && npm run typecheck && npm test && npm run build +``` + +Run SensApp on port 3000 next to it (`cargo run`: it prints a token and a link on a local run, or start it with `SENSAPP_AUTH_DISABLED=true` to work without one). + +`src/client` is generated from `openapi.json`, and both are committed. `openapi.json` is the OpenAPI document of the server: a Rust test fails when it is out of date. After an API change: + +```bash +UPDATE_OPENAPI=1 cargo test frontend_openapi_document # rewrites frontend/openapi.json +cd frontend && npm run openapi-ts # regenerates src/client +``` + +`VITE_SENSAPP_LIVE_URL=http://localhost:3000 npm test` also runs the test that talks to a running SensApp (without authentication: start it with `SENSAPP_AUTH_DISABLED=true`). diff --git a/docs/JWT_AUTH.md b/docs/JWT_AUTH.md index c344d2e0..16aad889 100644 --- a/docs/JWT_AUTH.md +++ b/docs/JWT_AUTH.md @@ -1,20 +1,40 @@ -# JWT Authentication +# Authentication -SensApp supports **optional** JWT (JSON Web Token) authentication. By default, all endpoints are open — just like Prometheus. When enabled, JWT tokens control read, write and delete access, and can optionally restrict which sensors a client may interact with. +SensApp is **not open unless you ask for it**. The protected endpoints need a JWT (JSON Web Token) signed with a secret that only SensApp and its operator know. A token has a scope (read, write, delete, admin) and can optionally restrict which sensors a client may interact with. -## Enabling Authentication +Tokens are stateless: SensApp signs them and checks the signature, and keeps nothing. That keeps several instances behind a load balancer in agreement with no shared state, other than the secret. -Set the `SENSAPP_JWT_SECRET` environment variable (or `jwt_secret` in `settings.toml`) to a secret string of **at least 32 characters**: +## Starting SensApp + +What happens at startup depends on the secret and on the address SensApp listens on (`SENSAPP_ENDPOINT`, `127.0.0.1` by default; the container image uses `0.0.0.0`): + +| `SENSAPP_JWT_SECRET` | `SENSAPP_AUTH_DISABLED` | Listens on | Result | +|---|---|---|---| +| set | any | any | Authentication on. | +| unset | `true` | any | **Open**: every endpoint answers anyone, with a warning. The explicit opt-out. | +| unset | unset | a loopback address (`127.0.0.1`, `::1`) | Authentication on with a **secret made for this run**. SensApp prints a token (all scopes, 24 hours) and a link to the UI that signs in with it. | +| unset | unset | any other address | **SensApp does not start** and says how to fix it. | + +The last row is the point: a container or a server has no business answering everyone because nobody configured it. The secret made on a loopback address only lives in the process, so a restart refuses the tokens made before; it is for a developer's machine, where only the local user can connect. Several instances behind a load balancer need the same configured secret, which is why they never get one made for them. ```bash -export SENSAPP_JWT_SECRET="my-super-secret-key-at-least-32-characters-long" +# A local run: SensApp prints a token and a link +cargo run + +# A secret that stays, for anything else +export SENSAPP_JWT_SECRET=$(sensapp generate-secret) + +# Open, for a demo, a test, or a network that authenticates in front of SensApp +export SENSAPP_AUTH_DISABLED=true ``` -When unset, authentication is disabled and all endpoints remain open. +The secret must be at least **32 characters**. `sensapp generate-secret` prints a random one of 48 characters (288 bits). Keep it private: anyone with the secret can make any token. In `settings.toml` it is `jwt_secret`, but an environment variable or a Secret is a better place than a file. -## Generating Tokens +## Making tokens -Use the built-in CLI command to generate signed tokens: +### With the command line + +The command needs the secret (`SENSAPP_JWT_SECRET`), so it runs where SensApp runs: ```bash # Read + write access, 1 hour validity (default) @@ -26,27 +46,48 @@ sensapp generate-token prometheus-scraper --scope read --duration 86400 # Write-only, restricted to specific sensors sensapp generate-token edge-device --scope write --sensors "temperature,humidity,pressure" -# Read + write (explicit) -sensapp generate-token admin --scope readwrite --duration 3600 +# A name with a comma in it: one --sensor for each name, kept as it is +sensapp generate-token edge-device --scope write --sensor "cpu,usage" --sensor "mem used" # Permission to delete series and samples, never granted by default sensapp generate-token cleanup --scope delete --duration 900 # Scopes can be combined -sensapp generate-token admin --scope readwrite,delete -``` +sensapp generate-token cleanup --scope readwrite,delete -The token is printed to stdout. +# A token that makes tokens, and that reads, to browse the data of the UI with it +sensapp generate-token me --scope read,admin +``` -### Options +The token is printed to stdout. In a container: `docker exec sensapp generate-token …`, in Kubernetes `kubectl exec deploy/ -- sensapp generate-token …`. | Flag | Description | Default | |------|-------------|---------| -| `--scope` | Comma-separated `read`, `write`, `delete`, or `readwrite` (shorthand for `read,write`) | `read write` (no `delete`) | -| `--duration` | Token validity in seconds | `3600` (1 hour) | +| `--scope` | Comma-separated `read`, `write`, `delete`, `admin`, or `readwrite` (shorthand for `read,write`) | `read write` | +| `--duration` | Token validity in seconds, at most ten years | `3600` (1 hour) | | `--sensors` | Comma-separated sensor name allow list | all sensors | +| `--sensor` | One sensor name, as it is (a comma is part of the name); repeat it for several. Can be mixed with `--sensors` | all sensors | -## Using Tokens +### With an admin token + +A token with the `admin` scope makes other tokens, through the Credentials tab of the [web UI](FRONTEND.md#credentials) or `POST /api/v1/admin/tokens`: + +```bash +curl -X POST http://localhost:3000/api/v1/admin/tokens \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"subject": "edge-device-7", "scope": ["write"], "sensors": ["temperature"], "duration_seconds": 86400}' +``` + +The answer holds the `token` and what it was made with (`subject`, `scope`, `sensors`, `expires_at`, `jti`). **The token is only shown there**: SensApp keeps nothing, so a token cannot be listed or looked up later. The answer carries `Cache-Control: no-store`. + +- `scope` is a list of `read`, `write` and `delete`. **The endpoint never makes an `admin` token** (403): those come from the command line, which has the secret. A stolen admin token therefore cannot renew itself, and an admin token that expires is replaced with one command. +- `duration_seconds` is at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (one year by default). +- The `admin` scope reads and writes nothing: it is as separate as `delete` is. The command the UI suggests asks for `read,admin`, so that the same token can browse the data and the web UI does not ask for another token. Make it `admin` alone for a token that only manages tokens. Whoever has it can still make a token for any scope, so keep it as private as the secret, and short. It lasts an hour by default. +- With authentication disabled there is nothing to sign with: the answer is `404`. +- Every creation is logged (who asked, for whom, the scope, the sensors, the expiry, the `jti`), never the token. + +## Using tokens Include the token in the `Authorization` header: @@ -54,7 +95,7 @@ Include the token in the `Authorization` header: Authorization: Bearer ``` -Example with curl: +`Authorization: Token ` is accepted too: it is how the InfluxDB clients (Telegraf's `outputs.influxdb_v2` `token`) send theirs. ```bash # Write data with a write token @@ -68,12 +109,24 @@ curl http://localhost:3000/metrics \ -H "Authorization: Bearer $TOKEN" ``` +## Scopes + +| Scope | Allows | Default | +|-------|--------|---------| +| `read` | Reading series, samples and queries | yes | +| `write` | Sending samples | yes | +| `delete` | Deleting series and samples, and the maintenance (`vacuum`) | no | +| `admin` | Making tokens (`POST /api/v1/admin/tokens`) | no | + +None implies another. A token without a `scope` claim has `read write`. + ## Route Protection | Endpoint | Auth Required | Scope | |----------|--------------|-------| | `GET /` | No | — | | `GET /docs` | No | — | +| `GET /ui/*` (the web UI files) | No | — | | `GET /health/live` | No | — | | `GET /health/ready` | No | — | | `GET /prometheus/metrics` | No for service metrics; yes when `include_latest_samples=true` | `read` for latest samples | @@ -88,12 +141,13 @@ curl http://localhost:3000/metrics \ | `POST /api/v2/write` | Yes | `write` | | `POST /api/v1/prometheus_remote_write` | Yes | `write` | | `POST /api/v1/admin/vacuum` | Yes | `delete` (it removes duplicate samples) | +| `POST /api/v1/admin/tokens` | Yes | `admin` | -Health checks, documentation, and Prometheus scrape endpoints are always public so that orchestration tools (Kubernetes probes, Prometheus scraper) work without tokens. +Health checks, documentation, the web UI files, and Prometheus scrape endpoints are always public so that orchestration tools (Kubernetes probes, Prometheus scraper) work without tokens. ## JWT Claims -Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: +Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims, and a `kid` in the header that names the secret that signed them: ```json { @@ -101,6 +155,9 @@ Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: "exp": 1700003600, "iat": 1700000000, "nbf": 1700000000, + "iss": "sensapp", + "aud": "sensapp", + "jti": "6f9c2f5e-4b1e-4c52-9a43-2f1c8f0f3a11", "scope": "read write", "sensors": ["temperature", "humidity"] } @@ -110,24 +167,63 @@ Tokens use the HS256 (HMAC-SHA256) algorithm with the following claims: |-------|----------|-------------| | `sub` | Yes | Subject — identifies who/what the token was issued to | | `exp` | Yes | Expiration time (Unix timestamp). Tokens are rejected after this time | +| `iss` | Yes | Issuer, `sensapp`. A token of another issuer is rejected, even signed with the right secret | +| `aud` | Yes | Audience, `sensapp`. Same | | `iat` | No | Issued-at time (informational) | | `nbf` | No | Not-before time. If present, the token is rejected before this time | -| `scope` | No | Space-separated among `"read"`, `"write"` and `"delete"`. Defaults to `"read write"`, which does not include `"delete"` | +| `jti` | No | Unique id of the token, in the logs of its requests. Every token SensApp makes has one | +| `scope` | No | Space-separated among `"read"`, `"write"`, `"delete"` and `"admin"`. Defaults to `"read write"` | | `sensors` | No | Array of allowed sensor names. If absent, all sensors are accessible | +A token made by hand (with a JWT library) needs `iss` and `aud` set to `sensapp`; without a `kid` it is checked against the current secret. + ### Time Validation - **`exp`** is always required and validated. Expired tokens are rejected with `401 Unauthorized`. - **`nbf`** is validated when present. Tokens presented before their not-before time are rejected. - The `jsonwebtoken` library applies a default leeway of 60 seconds for clock skew tolerance. +## Rotating the secret, and revoking tokens + +SensApp keeps no list of tokens, so a token cannot be revoked one by one: it works until it expires. What can be revoked is **a secret**, and with it every token it signed. That is also how the secret is changed without cutting the clients off at once: + +1. Generate a new secret (`sensapp generate-secret`). +2. Put it in `SENSAPP_JWT_SECRET`, and move the old one to `SENSAPP_JWT_PREVIOUS_SECRETS` (comma-separated, several are allowed). Restart the instances. New tokens are signed with the new secret; the tokens of the old one still verify. +3. Once those tokens have expired or the clients got new ones, remove the old secret from `SENSAPP_JWT_PREVIOUS_SECRETS`. + +To **revoke at once** (a leaked token, a lost admin token), do step 2 and leave the old secret out: every token it signed is refused. That includes the ones of the clients you trust, which need new tokens. The `kid` of a token tells which secret signed it, and a token whose `kid` is unknown is refused. Short durations are the other defence: prefer a day to a year, and an hour for an admin. + +Listing and revoking single tokens is a possible future feature (`ideas/token-registry-and-revocation.md`). + +## Logs + +The `subject` and the `token_id` (the `jti`) of an authenticated request are part of its log line, so the logs tell who did what: + +``` +INFO request{method=POST uri=/api/v2/write … subject="telegraf" token_id="0ff625ed-…"}: … +``` + +The token itself is never logged, and neither is the `Authorization` header. + +## API documentation + +The OpenAPI document (`/docs`) declares the bearer token as a security scheme and each protected operation names the scope it needs (`read`, `write`, `delete` or `admin`), `POST /api/v1/admin/tokens` included. `/prometheus/metrics` is public, and takes a `read` token for `include_latest_samples=true`. + ## Security Notes -- The JWT secret must be kept private — anyone with the secret can create valid tokens. -- Use a cryptographically random secret of at least 32 characters. -- For production deployments, consider short-lived tokens and rotate secrets periodically. -- The sensor allow list uses exact string matching on sensor names. +- The secret must be kept private — anyone with the secret can create valid tokens, an admin token included. Use `sensapp generate-secret`, or any cryptographically random string of at least 32 characters. +- Prefer short-lived tokens, rotate the secret now and then, and keep the admin token short. +- Run SensApp behind TLS when it is reachable over a network: a token is a bearer credential. +- The sensor allow list uses exact string matching on sensor names, spaces and commas included: the names are kept exactly as given. The JSON array of the endpoint and `--sensor` can hold a name with a comma, `--sensors` splits on commas. - When present, the sensor allow list applies to all API reads and writes, including Prometheus and InfluxDB compatibility endpoints. Catalog and selector results omit other sensors; direct series requests for another sensor return 404, and writes to another sensor return 403. -- With JWT authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. +- With authentication enabled, `/prometheus/metrics?include_latest_samples=true` requires a read token and applies its sensor allow list. Plain `/prometheus/metrics` stays public for service monitoring. - Sensor-scoped tokens cannot run the database-wide `/api/v1/admin/vacuum` operation. -- The `delete` scope is never implied by `read write`. It only allows deleting, not reading. With a sensor allow list, series the token cannot access are reported as not found. See [DATA_LIFECYCLE.md](DATA_LIFECYCLE.md). +- The `delete` and `admin` scopes are never implied by `read write`. `delete` only allows deleting, not reading. With a sensor allow list, series the token cannot access are reported as not found. See [DATA_LIFECYCLE.md](DATA_LIFECYCLE.md). + +## Web UI + +The UI (see [FRONTEND.md](FRONTEND.md)) is made of public static files. When authentication is on, its API calls get `401`, and it asks for a token: paste the output of `sensapp generate-token`, or open the link a local SensApp prints, which signs in by itself. The UI keeps the token for the browser tab only and sends it as `Authorization: Bearer`. A `read` token is enough to explore; a token without `read` gets `403`, which the UI reports the same way. The **Credentials** tab makes tokens for an admin token. + +## Kubernetes + +The [Helm chart](../charts/sensapp/README.md) makes a random secret on the first install and keeps it across upgrades; give `auth.jwtSecret` or `auth.existingSecret` to choose it, `auth.previousSecrets` to rotate it, and `auth.disabled=true` to run open. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 00000000..af936437 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/done/frontend-code-snippets-selector.md b/done/frontend-code-snippets-selector.md new file mode 100644 index 00000000..5e054c37 --- /dev/null +++ b/done/frontend-code-snippets-selector.md @@ -0,0 +1,39 @@ +# Frontend: code snippets, PEP 723 header and selectors + +Follow-up of `done/frontend-code-snippets.md`, same branch. + +- The Python snippet gets a PEP 723 block (`# /// script`) so `uv run script.py` installs the SDK from GitHub, next + to the `uv pip install` comment. +- The selector box of the series list (`{host="a"}`) moves from the local state of `SeriesTable` to the store, so the + dialog sees it. When it is set, the code asks the server for the series that match it (`list_series(metric=, + selector=)`; `curl -G --data-urlencode` and `jq` in the shell) instead of listing uuids, and a checkbox of the + dialog goes back to the selected series: the user may have unchecked some of what matches. +- A step only averages numbers. Series that match a selector are not known when the code is written, so the code + checks the type of each (`sensor_type` is `Float` on the wire, not `float`: lower-case it). + +## Steps + +1. [x] Selector in the store +2. [x] Generators: PEP 723, selector mode, tests +3. [x] Dialog checkbox, live check (uv run with the block, selector snippets against a server), docs + +## Results + +- `uv run` with only the PEP 723 block, from a directory outside the project, installed the SDK from GitHub `main` + and ran the script. ruff (88 columns) objected to the one-line `[tool.uv.sources]` table: it is the multi-line + `[tool.uv.sources.sensapp]` form now. +- Selector snippets run against a live SensApp: Python and curl return the same series as the chart for + `{host="a"}`; with a step on a selector that matches 4 series of 3 types (float, boolean, string) the floats come + back aggregated (12 points of 30 minutes) and the others raw (349), as the guard intends; the curl loop leaves the + non-numeric ones out. +- Found by reading the output: `\$uuid` (the escape of the page's text also escaped the variable) and a loop body + indented one level too far. Fixed, and tested. +- A hostile selector, metric and address (quotes, `$(…)`, backticks, newlines) were run in a real shell with `curl` + and `jq` stubbed: no command ran, and the loop got the literal address. +- 210 tests, lint, typecheck and build clean. Checkbox looked at in the browser. + +## Not done + +- curl and Python differ for a step on a selector: Python reads the non-numeric series raw, the shell loop leaves + them out (a per-series choice in `jq` would read worse than the rule it replaces). +- Pages: a selector that matches more than 256 series reads the first page only. diff --git a/done/frontend-code-snippets.md b/done/frontend-code-snippets.md new file mode 100644 index 00000000..039a20a2 --- /dev/null +++ b/done/frontend-code-snippets.md @@ -0,0 +1,44 @@ +# Frontend: code snippets modal + +Branch: `frontend-good-enough`. + +## Goal + +A button in the header (next to API Docs) opens a modal with a snippet that loads what the explorer shows: +the Python SDK (`sensapp`) in one tab, curl in the other. Simple, but well done. + +## Decisions + +- The snippet is generated from the state of the explorer: the selected series (uuid, with the name and labels in + a comment), the time window, the step, the aggregation. No series selected: the metric's series, or the metrics. +- A preset window (live) is written relative to now in Python (`datetime.now(UTC) - timedelta(...)`); in curl the + dates are absolute. A window of dates is absolute in both. +- The server is the origin of the page. Authentication: when the server asked for a token (or one is in use) the + snippet reads it from `SENSAPP_TOKEN`. The real token is never written in a snippet. +- Dark code block in both themes. JetBrains Mono (`@fontsource-variable`, latin, same as Roboto and Sora), + highlight.js core with Python and Bash only, loaded with the modal (not in the main bundle). A copy button. + +## Steps + +1. [x] Snippet generators (`src/lib/snippets.ts`) with tests +2. [x] Modal, highlighting, font, copy button +3. [x] Header button, tests, live check in a browser, docs (`docs/FRONTEND.md`) + +## Results + +- `src/lib/snippets.ts` (generators, 14 tests), `CodeDialog.tsx` and `CodeButton.tsx` (9 tests), `lib/copyText.ts`. + Full suite: 198 tests, lint, typecheck and build clean. +- Checked against a live SensApp (SQLite, seeded through the InfluxDB endpoint): the Python snippet and the curl + snippet were copied out of the dialog and run, they return the data of the chart (72 averaged points of 5 minutes + for 6 hours, and the same in CSV). With JWT enabled: the 401 makes the snippet use `SENSAPP_TOKEN`, and the same + snippet runs with a token in the environment and fails with the server's 401 without one. +- Every state (preset, dates, mixed types, auth, metric only, nothing, hostile labels) was generated and parsed by + Python's `ast` and `bash -n`; the ones with real uuids pass `ruff check` and `ruff format --check` of the SDK. +- Looked at in a browser, dark and light, desktop and a 375 px phone. +- The dialog chunk is 33.9 kB (13.6 kB gzipped), loaded on first use, and the font file (40 kB) is fetched only then. + +## Not done + +- The copy button was exercised by tests (the clipboard API and the `execCommand` fallback), not by a click in a + real browser: the pane does not grant the clipboard. +- Not checked in Safari or Firefox. diff --git a/done/frontend-good-enough.md b/done/frontend-good-enough.md new file mode 100644 index 00000000..b337e3b6 --- /dev/null +++ b/done/frontend-good-enough.md @@ -0,0 +1,62 @@ +# Frontend: good enough, minimal, served by SensApp + +Branch: `frontend-good-enough` (no pull request yet). + +## Goal + +A small, solid explorer UI (metrics, series, chart) that ships with the SensApp image and works with and +without JWT authentication. Keep it minimal. + +## Decisions + +- No separate container. The Docker build gets a Node stage that builds the frontend; the image carries + the static files and SensApp serves them under `/ui/`. +- SensApp serves the UI by default (`SENSAPP_UI_ENABLED=true`). When enabled, `/` redirects to `/ui/`. + The files come from `SENSAPP_UI_DIR`; a missing directory is a warning, not a startup failure. +- Authentication: there is no login endpoint (tokens come from `sensapp generate-token`), so on a 401 or 403 + the UI asks for a token, keeps it for the browser tab only (`sessionStorage`) and sends it as a Bearer token. + +## Steps + +1. [x] Analysis, dependency refresh +2. [x] Serve the UI under `/ui/` from SensApp (config, redirect, Docker stage, docs) +3. [x] Authentication in the frontend (token dialog, 401/403 handling, tests, live check against a JWT server) +4. [x] Code quality pass (error handling in one place, smaller bundle, dead code, README) +5. [x] Live check in a browser, then the next steps: `ideas/frontend-next-steps.md` + +## Results + +Dependencies: everything is at its latest major (Vite 8, Vitest 5, ESLint 10, jsdom 30) except TypeScript, held +at 5.9 because `typescript-eslint` does not support 7. `echarts-for-react` is gone (unmaintained, imports an +undeclared `tslib`): a 40 line wrapper on echarts core with only the line chart. `npm audit`: 0 findings. + +Build output: 51 asset files (1.7 MB) down to 9 (1.1 MB): Latin-only Roboto, echarts loaded only when a chart is +drawn (1 146 kB to 583 kB, 384 to 196 kB gzipped). The app itself is 103 kB gzipped. + +Serving: `src/http/ui.rs`, `SENSAPP_UI_ENABLED` / `SENSAPP_UI_DIR`, `/` redirects to `/ui/`. Public files with a +CSP, nosniff and no-cache. Verified in a container built from the Dockerfile (Node stage on the build platform, +files in `/usr/share/sensapp/ui`, runs as the non-root user), and `tests/clickhouse_container_smoke.py` now +checks it in CI. + +Authentication: verified against a live SensApp with JWT enabled and in a browser: no token, an invalid token +(401, "Invalid token: InvalidToken"), a write-only token (403, "Read access required"), a read token, a reload +(the token survives in the tab), Sign out (token and data forgotten, dialog again). + +Found on the way, fixed: `frontend/openapi.json` had drifted from the server (a test now guards it and the +client is regenerated); raw reads over 100 000 samples are refused by the server, so wide ranges are averaged by +a `step`; the page was unusable on a phone (clipped header and date range, a Metrics panel squeezed to a sliver). + +Found on the way, left for later (in `ideas/`): `string-series-aggregation-500.md` (server), +`metrics-tests-need-loaded-config.md` (two unit tests that depend on test order), `frontend-next-steps.md`. + +Review round (4 October 2026): the "averaged per" label in the chart header is gone (a UI that explains itself in +text is noise), the series list is paged with Previous/Next on the server's bookmark (tested live with 300 series; +`/series` now documents `limit` and `bookmark` in the OpenAPI document), and dark mode works: the daisyUI `dark` +theme was declared but not selected (`--prefersdark`), echarts now uses its dark theme and follows the OS. + +Second review round: step and aggregation selectors; the explorer in the address (`docs/FRONTEND.md`); booleans +drawn as 0/1 step lines; chart styles (line, step, area, stacked, bars) and a log scale. The canvas of the last two +was not looked at in a browser (the pane was hidden), only tested through the option builder and the requests. + +Not checked: the Dockerfile build for linux/arm64 (the Node stage is platform independent), and the complete +`docker-smoke` job with ClickHouse (only its new `check_ui` step, against an image with SQLite). diff --git a/done/frontend-load-data-tab.md b/done/frontend-load-data-tab.md new file mode 100644 index 00000000..aed24c84 --- /dev/null +++ b/done/frontend-load-data-tab.md @@ -0,0 +1,38 @@ +# Frontend: a "Load Data" tab + +Branch: `frontend-good-enough`. + +## Goal + +The header gets two tabs: **Data Explorer** (`/ui/`) and **Load Data** (`/ui/load`). The second one explains how +to get data into SensApp, with snippets that are ready to copy (same dark code block and copy button as the Code +dialog), filled with the address of the server the page is served from. + +Ways, as tabs of the page (`?via=` in the address): Python SDK, Telegraf, Prometheus, curl. Kept short on purpose: +enough to start, not a reference (the reference is `/docs`). + +- **Python SDK**: a big DataFrame (sent by slices), one sample at a time, a few samples at a time. +- **Telegraf**: `outputs.influxdb_v2` against `/api/v2/write`, with inputs. +- **Prometheus**: `remote_write` and `remote_read`. +- **curl**: SenML JSON, CSV, InfluxDB line protocol. + +When the server asks for a token the snippets read it from `SENSAPP_TOKEN` and say how to make a `write` one. + +## Steps + +1. [x] Header tabs, `/load` route (lazy), shared code block, snippets in `src/lib/loadSnippets.ts` + tests +2. [x] Check every snippet against a real SensApp (Python, Telegraf, curl, with and without a JWT secret) +3. [x] `docs/FRONTEND.md`, move this file to `done/` + +## Findings + +- All snippets were run against a real SensApp (PostgreSQL, one instance open and one with a JWT secret): 43 200 + samples of a DataFrame arrived in slices, curl's three formats, Telegraf with `--once`, and `promtool check config` + accepts the Prometheus files. (No live Prometheus run: the endpoints are covered by the server tests.) +- **Telegraf's `token` does not work with JWT**: it sends `Authorization: Token ...`, SensApp wants `Bearer`, and + answered `401`. The snippet overrides the header with `http_headers`, checked live. Written in + `ideas/influxdb-token-authorization-scheme.md`. +- InfluxDB measurement and field make the series name with a space (`cpu usage_idle`), not an underscore. +- Prometheus's token is a file: the tab gives the command, with a year of validity (the default is an hour). +- The Code button is about the explorer, so it is hidden on the Load Data tab. +- Not done on purpose: Grafana, MQTT, other languages, reading data back on this tab (the Code dialog does it). diff --git a/done/frontend-review-round-3.md b/done/frontend-review-round-3.md new file mode 100644 index 00000000..f974e085 --- /dev/null +++ b/done/frontend-review-round-3.md @@ -0,0 +1,51 @@ +# Frontend: third review round (5 October 2026) + +Branch: `frontend-good-enough`. Comments from the user after using the UI. + +## Decisions + +- **"N series" in the chart header**: removed, the series list says it (`Clear (3)`, `3 selected`). +- **End time is now**: a preset range is re-resolved every minute while the tab is visible, when the tab + comes back to the front, and when the active preset is clicked again. Queries keep their previous data + while the new range loads (no flicker), the zoom of the chart is kept. +- **Metric names**: a maximum width on the name column, truncated, the full name in the tooltip. +- **Selecting a metric selects its series** when there are at most 8 (the size of the palette: more would + repeat colours and the chart would be noise). More than that: the user picks. +- **Series list**: one column per label dimension, one row per series, the uuid small and last. The Type + column only appears when the series of the metric are not all of one type. This can happen: the server + groups metrics by (name, type) and `/series?metric=` filters on the name only, so a name that exists + as a float and as a string is two rows in the metrics list and one list of series. +- **Colours belong to the series list**: a swatch on each selected row, the echarts legend is gone. The + colour of a series is fixed while it is selected (lowest free slot), disabling another one does not + repaint it. Toggling a series animates in the chart (merge instead of a full replace). +- **"Connected" badge**: removed. A server that is down already shows the error of the request. +- **Logo, favicon, theme**: the SensApp logo in the header (light, dark and small variants of `public/`), a + favicon made from it, a theme taken from the logo blue, and the validated categorical palette of the + dataviz skill for the series (light and dark steps). + +## Steps + +1. [x] Small cleanups: header count, health badge, metric name width, metric row keys +2. [x] Live range (`hooks/useLiveRange.ts`). `keepPreviousData` does not work with `useQueries` (a new key is a + new observer), so the chart keeps the last data of each series itself. +3. [x] Series list: dimension columns, small uuid, type when mixed, colour swatch, auto-select +4. [x] Chart: no legend, animated toggles, stable colours (`lib/palette.ts`) +5. [x] Logo, favicon, theme, palette +6. [x] Docs (`docs/FRONTEND.md`), live check in a browser + +## Results + +Verified in a browser against a live SensApp (SQLite, Influx-written data, dark and light, 1280 and 375 px): +auto-select of a 3-series metric with swatches matching the chart, no auto-select of a 12-series one, +unselecting a series leaves the colours of the others and reselecting gives it its colour back, a zoom +survives toggling, the range moved by itself after a minute, the metrics table does not scroll sideways. +`npm test` (130), lint, typecheck and build pass. + +Found on the way: the server sends `Float` and `String`, so the colours of the metric type badges, chosen on +`float` and `string`, never applied. Fixed. Not looked at: the animation itself (only its result), the dark +logo in the pane at 1280 px. + +Left (in `ideas/`): `frontend-constant-label-columns.md` (the Influx importer adds `influxdb_org` and +`influxdb_bucket` to every series, two useless columns), and more than 8 series at once (`frontend-next-steps.md`). +The original logo files in `frontend/public/` (`sensapp_logo.png`, `_white`, `_small`, `_small_white`) are not +committed: the page uses the `-fs8` ones. diff --git a/done/frontend-review-round-4.md b/done/frontend-review-round-4.md new file mode 100644 index 00000000..105a87f6 --- /dev/null +++ b/done/frontend-review-round-4.md @@ -0,0 +1,62 @@ +# Frontend: fourth review round (5 October 2026) + +Branch: `frontend-good-enough`. + +## Time: one window, one place + +The problem: the chart had two ways to choose what it shows. The time range control (presets, dates) decided what is +fetched, and the echarts zoom slider, the wheel and the toolbox zoomed into what was already fetched. Zooming +showed the same coarse points bigger (a day averaged per minute, zoomed on 15 minutes, is 15 points), the slider +showed the very thing the chart shows, and nothing said how to get back. + +Decision: **the time window of the store is the only window.** The chart draws exactly that window. + +- Drag on the chart (a horizontal brush, native echarts) = zoom: it sets the window, the data is fetched again at + the step that fits it (a few seconds of raw samples when zoomed in). No slider, no inside zoom, no wheel (the + wheel scrolls the page on small screens). +- Navigation below the chart, where the thumb and the eyes are after looking at it: `‹` `›` pan by half a window, + `−` zooms out (twice the window, or the next preset for a live range), `↶` goes back to the window before (a + history of the last 20 windows made by the user, not by the clock), then the presets, the dates, the step and the + aggregation. The chart header keeps what is about the drawing (style, log). +- While the new data loads the chart keeps the old points on the new axis (an instant, coarse zoom that + sharpens), with a thin progress bar on top instead of a line of text that moves the chart. +- Not done: an overview of the whole life of the series in place of the slider (`/series/{uuid}/availability` + could feed it). It would be a third thing to look at; written in `ideas/`. + +## Smaller + +- Header: same horizontal padding as the cards so the logo lines up with them. The image has no padding of its + own: what looks like space is the pale dish. +- Series list sorted by the first label column, then the next ones, numbers in natural order (`node-2` before + `node-10`); a click on a column header sorts by it. The server pages by creation order, so the sort is of the + page on screen. +- The placeholder of the selector is made of the labels of the first series. +- The checkbox takes the color of the series (no more dot). +- Labels that are the same on every series are said once above the table, not in a column (Influx org and bucket, + which the importer adds on purpose: they are part of the identity of a series). +- More than 8 series: 24 colors, the validated 8 then 16 chosen by farthest-point sampling in OKLab under normal + and color-blind vision; hovering a row highlights its series in the chart, because past 8 color alone does not + say which is which. + +## Steps + +1. [x] Header alignment (same padding as the cards) +2. [x] Time: store (history, pan, zoom out), the bar below the chart, brush zoom, progress bar +3. [x] Series list: sort, placeholder, colored checkbox, shared labels (also trimmed from the chart names) +4. [x] 24 colors, hover highlight, select all +5. [x] Docs, live check + +## Results + +Checked in a browser against a live SensApp: a drag on the chart sets the window (the address gets `from` and +`to`), the data is read again and finer, Back is enabled; 12 series each with a color of its own; hovering a row +makes its line stand out. `npm test` (171), lint, typecheck, build pass. + +The palette: the light one stays in the validated lightness band, the dark one goes outside it (up to L 0.84) for +the 16 extra colors: inside it there was no room (neighbours 4 apart). Numbers in `docs/FRONTEND.md`. The skill's +normal-vision floor (15 between neighbours) cannot hold past 8 colors, which is what the hover is for. + +Found on the way: echarts adds buttons for its brush to the toolbox whatever is said (`brush.toolbox: []` brings +the defaults back), so the toolbox is off (and so the save-as-image button with it); the header says "Drag on +the chart to zoom". `ideas/influxdb-labels-without-tags.md`: the importer adds org and bucket only to lines that +have tags. Not looked at: the logo alignment on a phone. diff --git a/done/frontend-review-round-5.md b/done/frontend-review-round-5.md new file mode 100644 index 00000000..e756f199 --- /dev/null +++ b/done/frontend-review-round-5.md @@ -0,0 +1,39 @@ +# Frontend: fifth review round (5 October 2026) + +Branch: `frontend-good-enough`. + +- **Width.** The header and the page were capped at 1280 px (`max-w-7xl`, from the first layout): a wide window had + margins on both sides. Both use the whole window now, and the chart is `clamp(260px, 40vh, 640px)` high instead + of a fixed 260 px, so a tall window gives it room. +- **"Drag on the chart to zoom"** removed: the crosshair cursor and the selection that follows the pointer say it, + and `docs/FRONTEND.md` does. +- **Controls, one look in both themes.** daisyUI draws outline buttons and badges in the full text color, which is + white frames on the dark theme. `index.css` has, outside the layers so that it wins: a border that is 18 % of the + text color (32 % on hover) for buttons and fields, `btn-quiet` (a wash on hover, tinted with the primary color + when it is on: the active preset, `log`), `chip` (a wash, no frame) for labels and dimensions, soft type badges, + a hover wash that is not darker than the card, and thin scrollbars that are not brighter than the page. +- Dimensions of the metrics list wrap in a wider cell (the name takes 40 %, not everything). + +Looked at in a browser, light and dark, at 1280 and 1600 px wide. Not looked at: the phone width, which these +changes do not touch. + +## Vertical space (same day) + +- The controls of the chart (back, earlier, zoom out, later, presets, the two dates on one line, step, aggregation) + moved from a bar below the chart into its header, next to the style and the scale. This undoes the "below the + chart" of the fourth round, on purpose: the room is worth more to the chart. +- `Panel` (`components/Panel.tsx`): the title and the controls of the Metrics and Series cards share one row. The + series count is there too ("3 of 12 series", "on this page" with a pager) and the footer of the list is gone; + "3 selected" went, `Clear (3)` says it. +- A radio button on each metric. When the page opens on a metric (the address) the list scrolls to it, once; a + click does not move the list. + +## Spacing, hover, title (same day) + +- The page had 24 px at the sides (`lg:px-6`) and 12 px between the cards and below: it is 12 px (`p-3`, `gap-3`) + all around now, the header included, so the logo lines up with the cards. +- Hover: the ghost buttons (API Docs, Clear, Sign out) had daisyUI's hover, the bar buttons their own. Both use + the same wash now (`--wash`); the quiet ones also darken their border. "Sign in" was an outline in the primary + color that filled on hover: it is a quiet button too. +- "Sensor Data Explorer" is "Data Explorer", in capitals with wide tracking, in Sora (variable, latin only, + 34 kB, `@fontsource-variable/sora`), the one place it is used (`font-display`). diff --git a/done/influxdb-token-authorization-scheme.md b/done/influxdb-token-authorization-scheme.md new file mode 100644 index 00000000..551493f0 --- /dev/null +++ b/done/influxdb-token-authorization-scheme.md @@ -0,0 +1,16 @@ +# InfluxDB clients send `Authorization: Token`, SensApp reads `Bearer` + +Found while writing the Load Data tab (5 October 2026). Telegraf's `outputs.influxdb_v2` sends its `token` as +`Authorization: Token `, as InfluxDB v2 clients do. `src/http/auth.rs` only strips `Bearer `, so with +`SENSAPP_JWT_SECRET` set a stock Telegraf (or the InfluxDB client libraries) gets `401`. + +Today's workaround, in the UI snippet and `docs/FRONTEND.md`: `http_headers = {"Authorization" = "Bearer ${SENSAPP_TOKEN}"}`. + +The idea: accept the `Token ` scheme as well on the InfluxDB write route (`/api/v2/write`) only, or everywhere +(one more `strip_prefix`). It makes "update the URL and credentials", the promise of `docs/INFLUX_DB.md`, true with +authentication on. Small change, with a test on each route; `docs/JWT_AUTH.md` and `docs/INFLUX_DB.md` to update, +then the `http_headers` lines of the Load Data snippet (`src/lib/loadSnippets.ts`) to drop. + +## Done + +Closed with the secure-by-default authentication work (October 2026): `validate_token` accepts `Authorization: Token ` as well as `Bearer`, on every route (tests in `src/http/auth.rs` and `tests/integration/jwt_auth.rs`, and a live `POST /api/v2/write` with the `Token` scheme). The Load Data snippet now gives Telegraf its own `token = "${SENSAPP_TOKEN}"`, without the `http_headers` workaround. The docs are updated with the rest of that work. diff --git a/done/remote-read-hints-semantics.md b/done/remote-read-hints-semantics.md new file mode 100644 index 00000000..ea2c7e64 --- /dev/null +++ b/done/remote-read-hints-semantics.md @@ -0,0 +1,36 @@ +# Prometheus remote read hints: only aggregate when Prometheus gets the right answer + +## Question + +When Prometheus asks for `avg_over_time(x[1h])` over a year with a one hour step, does SensApp send all the +samples, or aggregate in the database? + +## Findings (Prometheus v3.8.0, v3.13.4 and v3.15.0, SensApp on PostgreSQL, 6 hours of one sample a minute) + +SensApp aggregated in the database (one bucket per `step`, bulk on every SQL backend and BigQuery). But +Prometheus evaluates the query again on what it receives, and the buckets only gave the right answer in +some cases. The buckets start at `query.start_timestamp_ms`, which Prometheus 3 sets to the start of the +first window (`t - range + 1 ms`), so for a range equal to the step a bucket is exactly one window: the +shift of one bucket I feared does not happen. + +| Query (step 1h unless said) | Before | Why | +| --- | --- | --- | +| `avg/min/max/sum/last_over_time(x[1h])` | right | one bucket per window | +| `count_over_time(x[1h])` | 1 instead of 60 | Prometheus counts the buckets | +| `avg_over_time(x[30m])`, `max_over_time(x[10m])` at 15m | wrong | the bucket is wider than the window (`range_ms` < `step_ms`) | +| `sum(x)`, `avg(x)`, `min(x)`, `max(x)` across series | `sum` 36004 instead of 576 | `func` is the operator, `range_ms` is 0: Prometheus wants the value at each step, not the aggregate of the following hour | +| `avg_over_time(x[2h])` | right when the buckets are full | average of averages: a compromise, kept | + +## Done + +- [x] `aggregation_from_read_hints` only maps the mergeable `*_over_time` functions (no `count`, no bare operator names). +- [x] `query_options_from_read_hints` requires `range_ms` to be a positive multiple of `step_ms`; otherwise raw samples. +- [x] Unit tests of both rules; the existing backend-generic hint test (range = step) still passes. +- [x] `tests/prometheus_live/test_live.py::test_remote_read_hints`: 20 queries through a real Prometheus compared with the raw samples (it + failed on 8 of them before the change). `run.sh` now takes `PROMETHEUS_IMAGE` and defaults to v3.13.4 (the LTS line, supported until July 2027; v3.15.0, the latest release, passes too). +- [x] Documented in `docs/DATAMODEL.md`. + +## Not checked + +- Prometheus 2.x: not supported or tested (the harness runs the 3.x LTS line). +- A big range at a small step with raw fallback is now an error above the sample limits (HTTP 400, "narrow the selector") instead of a wrong answer. diff --git a/done/secure-by-default-auth.md b/done/secure-by-default-auth.md new file mode 100644 index 00000000..05d0777d --- /dev/null +++ b/done/secure-by-default-auth.md @@ -0,0 +1,39 @@ +# Secure by default authentication, admin scope, credentials tab + +## Goal + +SensApp does not run open unless told to, an `admin` scope can mint tokens (CLI and `POST /api/v1/admin/tokens`), and the UI has a Credentials tab to create tokens. + +## Context + +Until now authentication was opt-in (`SENSAPP_JWT_SECRET`), tokens came only from `sensapp generate-token`, there was no audit of who did what, no secret rotation and InfluxDB clients' `Authorization: Token` was refused. The plan is in the session that created this file; decisions taken with the user: + +- Jupyter-style default: no secret on a loopback address makes an in-memory secret and prints a token plus a UI link; no secret on another address refuses to start unless `SENSAPP_AUTH_DISABLED=true`. +- Stateless: no token registry, so no list or revoke in the UI. Revocation is secret rotation (`SENSAPP_JWT_PREVIOUS_SECRETS`). The registry is in `ideas/token-registry-and-revocation.md`. +- Admin tokens are minted on demand from the secret (CLI), short-lived by default. The endpoint never mints `admin` tokens. + +## Steps (one commit each) + +1. Startup rule: `resolve_auth_mode`, `SENSAPP_AUTH_DISABLED`, `generate-secret`. +2. Token model: `admin` scope, `jti`, `iss`/`aud`, `kid` and previous secrets, `Token` scheme, subject in the logs, shared token request validation. +3. `POST /api/v1/admin/tokens`, OpenAPI and the regenerated client. +4. Frontend: Credentials tab, `#token=` pickup, the Telegraf header workaround removed. +5. Helm (a made secret, `existingSecret`, `previousSecrets`, `disabled`), the container, the compose file, the perf and live scripts and the notebook (explicit `SENSAPP_AUTH_DISABLED`), a CI step that the image refuses to start open, and the docs. + +## Result + +Done on the branch `frontend-good-enough`, in five commits. See `docs/JWT_AUTH.md` for the behaviour. What was verified, and what was not: + +- Verified: the full Rust suite on SQLite and PostgreSQL (clippy and fmt clean), the frontend (typecheck, lint, 243 tests, `npm audit`), the real binary (the three startup modes, `Token` scheme, the subject in the logs), the UI in the browser pane against a local SensApp (the link signs in, the Credentials tab makes a token that is limited as asked), `helm lint` and `helm template` with the four auth setups. +- Not verified: the Helm `lookup` that keeps the made secret across upgrades (no cluster here), the new CI step that checks the image refuses to start (it only runs in CI; its command was replayed with the binary), and the other backends (TimescaleDB, DuckDB, ClickHouse), which the tests of this task do not depend on but CI runs. +- Left out on purpose: listing and revoking single tokens, in `ideas/token-registry-and-revocation.md`. +- The README (human-owned) still says authentication is optional and that all endpoints are open by default: it needs the wording proposed in the session. + +## Progress + +- Step 1 done: `resolve_auth_mode`, `SENSAPP_AUTH_DISABLED`, `generate-secret`. The dev token of a made secret has every scope (including `admin`) and lasts 24 hours, because the CLI cannot mint another one with a secret that only lives in the process. +- Step 2 done: `admin` scope, `jti`, `iss`/`aud` (tokens made by hand need them), `kid` and `SENSAPP_JWT_PREVIOUS_SECRETS`, the `Token` scheme, `subject` and `token_id` in the request log span, `TokenRequest` shared by the CLI and the future endpoint. +- Found: `http::server::tests::frontend_openapi_document_is_up_to_date` already failed before this task (the saved `frontend/openapi.json` is stale); step 3 regenerates it. +- Step 3 done: `POST /api/v1/admin/tokens` (admin scope; refuses `admin` tokens; duration capped by `SENSAPP_TOKEN_MAX_DURATION_SECONDS`, one year by default, held by `AuthConfig`; `Cache-Control: no-store`; 404 when authentication is disabled; audit log line without the token). OpenAPI and the frontend client regenerated. +- Step 4 done: Credentials tab (`frontend/src/pages/CredentialsPage.tsx`: open-server and not-admin states, the form, the token shown once), `#token=` pickup (`lib/tokenFromAddress.ts`), refused mutations ask for a token like refused queries, the Telegraf snippet uses its own `token`. Checked in the browser pane against a local SensApp: the printed link signs in and drops the fragment, the made token only sees its sensors and cannot write others nor make tokens. `ideas/influxdb-token-authorization-scheme.md` moved to `done/`. +- Step 5 done: chart, container, CI, scripts, notebook, docs (`JWT_AUTH.md` rewritten, `CONFIGURATION.md`, `FRONTEND.md`, chart README, `DATA_LIFECYCLE.md`), `TODO.md`. diff --git a/frontend/README.md b/frontend/README.md index 4dcad1f9..bbcbfd4a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,16 @@ -# React + TypeScript + Vite +# SensApp frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The explorer UI of SensApp: React 19, TypeScript, Vite, Tailwind with daisyUI, TanStack Query, Zustand and ECharts. The build is served by SensApp under `/ui/`. -Currently, two official plugins are available: +See [docs/FRONTEND.md](../docs/FRONTEND.md) for how it is served, how it authenticates and how to develop it. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm ci +npm run dev # http://localhost:5173/ui/, the API is proxied to http://localhost:3000 +npm run lint +npm run typecheck +npm test +npm run build # dist/, served by SensApp (SENSAPP_UI_DIR) ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +`src/client` is generated from `openapi.json` by `npm run openapi-ts`, and committed. diff --git a/frontend/index.html b/frontend/index.html index 592cc7f1..21c54905 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + SensApp diff --git a/frontend/openapi.json b/frontend/openapi.json index 3707078d..dce8a452 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -1 +1,1473 @@ -{"openapi":"3.1.0","info":{"title":"sensapp","description":"","license":{"name":""},"version":"0.3.0"},"paths":{"/":{"get":{"tags":["SensApp"],"operationId":"frontpage","responses":{"200":{"description":"SensApp Frontpage","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/v1/admin/vacuum":{"post":{"tags":["Admin"],"summary":"Database Vacuuming","description":"Cleans up and optimizes the database by removing unused data and reclaiming space.\n(only if supported by the underlying storage engine).","operationId":"vacuum_database","responses":{"200":{"description":"Database vacuum completed successfully","content":{"text/plain":{"schema":{"type":"string"}}}},"500":{"description":"Failed to vacuum database","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/v1/prometheus_remote_read":{"post":{"tags":["Prometheus"],"summary":"Prometheus Remote Read API.","description":"Allows you to read data from SensApp using Prometheus remote read protocol.\n\nIt follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/).","operationId":"prometheus_remote_read","parameters":[{"name":"content-encoding","in":"header","description":"Content encoding, must be snappy","required":true,"schema":{"type":"string","format":"snappy"}},{"name":"content-type","in":"header","description":"Content type, must be application/x-protobuf","required":true,"schema":{"type":"string","format":"application/x-protobuf"}},{"name":"x-prometheus-remote-read-version","in":"header","description":"Prometheus Remote Read version, must be 0.1.0","required":true,"schema":{"type":"string","format":"0.1.0"}}],"requestBody":{"description":"Prometheus Remote Read endpoint. [Reference](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/)","content":{"application/x-protobuf":{}}},"responses":{"200":{"description":"Read Response","content":{"application/x-protobuf":{}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/api/v1/prometheus_remote_write":{"post":{"tags":["Prometheus"],"summary":"Prometheus Remote Write API.","description":"Allows you to write data from Prometheus to SensApp.\n\nIt follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/).","operationId":"publish_prometheus","parameters":[{"name":"content-encoding","in":"header","description":"Content encoding, must be snappy","required":true,"schema":{"type":"string","format":"snappy"}},{"name":"content-type","in":"header","description":"Content type, must be application/x-protobuf","required":true,"schema":{"type":"string","format":"application/x-protobuf"}},{"name":"x-prometheus-remote-write-version","in":"header","description":"Prometheus Remote Write version, must be 0.1.0","required":true,"schema":{"type":"string","format":"0.1.0"}}],"requestBody":{"description":"Prometheus Remote Write endpoint. [Reference](https://prometheus.io/docs/concepts/remote_write_spec/)","content":{"application/x-protobuf":{}}},"responses":{"204":{"description":"No Content"},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/api/v1/query":{"get":{"tags":["SensApp"],"summary":"Simple PromQL query endpoint.","description":"Parses a PromQL expression and returns matching time series data.\nOnly simple selectors (VectorSelector and MatrixSelector) are supported.\n\n# Examples\n\n- Simple metric: `GET /api/v1/query?query=my_metric`\n- With labels: `GET /api/v1/query?query=my_metric{env=\"prod\"}`\n- Range query: `GET /api/v1/query?query=my_metric[5m]`\n- With format: `GET /api/v1/query?query=my_metric&format=csv`","operationId":"simple_promql_query","parameters":[{"name":"query","in":"query","description":"PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"Output format: senml (default), csv, jsonl, or arrow","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Query results in requested format","content":{"application/json":{"schema":{}}}},"400":{"description":"Invalid or unsupported PromQL query"},"500":{"description":"Internal server error"}}}},"/api/v2/write":{"post":{"tags":["InfluxDB"],"summary":"InfluxDB Compatible Write API.","description":"Allows you to write data from InfluxDB or Telegraf to SensApp.\n[More information.](https://github.com/SINTEF/sensapp/blob/main/docs/INFLUX_DB.md)","operationId":"publish_influxdb","parameters":[{"name":"bucket","in":"query","description":"Bucket name","required":true,"schema":{"type":"string"},"example":"sensapp"},{"name":"org","in":"query","description":"Organization name","required":false,"schema":{"type":"string"},"example":"sensapp"},{"name":"org_id","in":"query","description":"Organization ID","required":false,"schema":{"type":"string"}},{"name":"precision","in":"query","description":"Precision of the timestamps. One of ns, us, ms, s","required":false,"schema":{"type":"string"}}],"requestBody":{"description":"InfluxDB Line Protocol endpoint. [Reference](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/).","content":{"text/plain":{"schema":{"type":"string"},"example":"cpu,host=A,region=west usage_system=64.2 1590488773254420000"}},"required":true},"responses":{"204":{"description":"No Content"},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/health/live":{"get":{"tags":["Health"],"summary":"Liveness check","description":"Checks if the service is running.\nThis endpoint always returns 200 OK if the server is able to respond.","operationId":"liveness","responses":{"200":{"description":"Service is alive","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/health/ready":{"get":{"tags":["Health"],"summary":"Readiness check","description":"Checks if the service is ready to accept traffic.\nThis includes checking if the database connection is working","operationId":"readiness","responses":{"200":{"description":"Service is ready","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}},"503":{"description":"Service is not ready","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}}}}}},"/metrics":{"get":{"tags":["SensApp"],"summary":"List unique metrics (measurement types) with aggregated information in DCAT catalog format.","operationId":"list_metrics","parameters":[{"name":"name","in":"query","description":"Filter metrics by name (substring match)","required":false,"schema":{"type":"string"}},{"name":"name_regex","in":"query","description":"Filter metrics by name using regex pattern","required":false,"schema":{"type":"string"}},{"name":"type","in":"query","description":"Filter metrics by sensor type (float, integer, string, boolean, location, json, blob, numeric)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metrics catalog in DCAT format","content":{"application/json":{"schema":{}}}}}}},"/prometheus/metrics":{"get":{"tags":["Observability"],"operationId":"prometheus_metrics","responses":{"200":{"description":"Prometheus-compatible metrics","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/publish":{"post":{"tags":["SensApp"],"summary":"SensApp native data ingestion API supporting multiple formats.","description":"Accepts sensor data in one of the following formats:\n- **SenML JSON** (RFC 8428): `Content-Type: application/json`\n- **CSV**: `Content-Type: text/csv` or `application/csv`\n- **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.file`\n\nIf no Content-Type header is provided, defaults to CSV format.","operationId":"publish_sensors_data","requestBody":{"description":"Sensor data in SenML JSON, CSV, or Apache Arrow format","content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"Data ingested successfully","content":{"text/plain":{"schema":{"type":"string"}}}},"400":{"description":"Bad Request - invalid data format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppError"}}}}}}},"/series":{"get":{"tags":["SensApp"],"summary":"List all series (time series) in DCAT catalog format.","operationId":"list_series","parameters":[{"name":"metric","in":"query","description":"Filter series by metric name","required":false,"schema":{"type":"string"}},{"name":"selector","in":"query","description":"PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Time series catalog in DCAT format","content":{"application/json":{"schema":{}}}}}}},"/series/{series_uuid}":{"get":{"tags":["SensApp"],"summary":"Get series data in various formats based on query parameter.","operationId":"get_series_data","parameters":[{"name":"series_uuid","in":"path","description":"UUID of the series","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"Output format: senml, csv, or jsonl (default: senml)","required":false,"schema":{"type":"string"}},{"name":"start","in":"query","description":"Start datetime in ISO 8601 format (e.g., '2024-01-15T10:30:00Z')","required":false,"schema":{"type":"string"}},{"name":"end","in":"query","description":"End datetime in ISO 8601 format (e.g., '2024-01-15T11:00:00Z')","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Maximum number of samples (default: 10,000,000)","required":false,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Series data in requested format","content":{"application/json":{"schema":{}}}},"400":{"description":"Invalid format"},"404":{"description":"Series not found"}}}}},"components":{"schemas":{"AppError":{"oneOf":[{"type":"object","required":["InternalServerError"],"properties":{"InternalServerError":{"type":"string"}},"example":"Internal Server Error"},{"type":"object","required":["BadRequest"],"properties":{"BadRequest":{"type":"string"}},"example":"Bad Request"},{"type":"object","required":["NotFound"],"properties":{"NotFound":{"type":"string"}},"example":"Not Found"},{"type":"object","required":["Unauthorized"],"properties":{"Unauthorized":{"type":"string"}},"example":"Unauthorized"},{"type":"object","required":["Forbidden"],"properties":{"Forbidden":{"type":"string"}},"example":"Forbidden"},{"type":"object","required":["Storage"],"properties":{"Storage":{"type":"string"}},"example":"Storage Error"}]},"HealthResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"ReadinessResponse":{"type":"object","required":["status","database"],"properties":{"database":{"type":"string"},"error":{"type":["string","null"]},"status":{"type":"string"}}}}},"tags":[{"name":"SensApp","description":"SensApp API"},{"name":"InfluxDB","description":"InfluxDB Write API"},{"name":"Observability","description":"Prometheus-compatible service metrics"},{"name":"Prometheus","description":"Prometheus Remote Write and Read API"},{"name":"Admin","description":"Administrative operations"},{"name":"Health","description":"Health check endpoints"}]} +{ + "components": { + "schemas": { + "AppError": { + "oneOf": [ + { + "example": "Internal Server Error", + "properties": { + "InternalServerError": { + "type": "string" + } + }, + "required": [ + "InternalServerError" + ], + "type": "object" + }, + { + "example": "Bad Request", + "properties": { + "BadRequest": { + "type": "string" + } + }, + "required": [ + "BadRequest" + ], + "type": "object" + }, + { + "example": "Not Found", + "properties": { + "NotFound": { + "type": "string" + } + }, + "required": [ + "NotFound" + ], + "type": "object" + }, + { + "example": "Unauthorized", + "properties": { + "Unauthorized": { + "type": "string" + } + }, + "required": [ + "Unauthorized" + ], + "type": "object" + }, + { + "example": "Forbidden", + "properties": { + "Forbidden": { + "type": "string" + } + }, + "required": [ + "Forbidden" + ], + "type": "object" + }, + { + "example": "Storage Error", + "properties": { + "Storage": { + "type": "string" + } + }, + "required": [ + "Storage" + ], + "type": "object" + } + ] + }, + "CreateTokenRequest": { + "description": "What to put in a token: who it is for, what it may do, for how long.", + "properties": { + "duration_seconds": { + "description": "Validity of the token, in seconds, at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (a year by\ndefault).", + "example": 86400, + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "scope": { + "description": "What the token may do, among `read`, `write` and `delete`. `admin` tokens are only made with\n`sensapp generate-token`.", + "example": [ + "read", + "write" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "sensors": { + "description": "Names of the sensors the token may access. Leave it out for every sensor.", + "example": [ + "temperature", + "humidity" + ], + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "subject": { + "description": "Who or what the token is for: a service, a device, a person. It is in the logs of every\nrequest the token makes.", + "example": "edge-device-7", + "type": "string" + } + }, + "required": [ + "subject", + "scope", + "duration_seconds" + ], + "type": "object" + }, + "CreatedToken": { + "properties": { + "expires_at": { + "description": "Expiration time, Unix timestamp in seconds", + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "jti": { + "description": "Unique id of the token, in the logs of the requests it makes", + "type": "string" + }, + "scope": { + "items": { + "type": "string" + }, + "type": "array" + }, + "sensors": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "subject": { + "type": "string" + }, + "token": { + "description": "The token, to use as `Authorization: Bearer `. This is the only time it is shown:\nSensApp does not keep tokens.", + "type": "string" + } + }, + "required": [ + "token", + "subject", + "scope", + "expires_at", + "jti" + ], + "type": "object" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ReadinessResponse": { + "properties": { + "database": { + "type": "string" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "database" + ], + "type": "object" + }, + "VacuumResponse": { + "description": "What a vacuum did.", + "properties": { + "duplicates_removed": { + "description": "Number of duplicate samples removed (same series, same timestamp, same value, the first\none written is kept), or `null` when the storage backend cannot remove duplicates", + "format": "int64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "status": { + "description": "`ok` when the maintenance completed", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearer": { + "bearerFormat": "JWT", + "description": "A JWT signed with the secret of the server, sent as `Authorization: Bearer ` (`Authorization: Token `, which InfluxDB clients send, works too). Each operation lists the scope it needs: `read`, `write`, `delete` or `admin`. Make one with `sensapp generate-token`, or with `POST /api/v1/admin/tokens` and an `admin` token (the Credentials tab of the web UI does). With authentication disabled (`SENSAPP_AUTH_DISABLED`) no token is needed.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Sensor data ingestion, storage, and retrieval platform", + "license": { + "identifier": "Apache-2.0", + "name": "Apache-2.0" + }, + "title": "sensapp", + "version": "0.3.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "frontpage", + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served" + } + }, + "tags": [ + "SensApp" + ] + } + }, + "/api/v1/admin/tokens": { + "post": { + "description": "Makes a signed token for a client. SensApp does not keep tokens: the answer is the only time\nthe token is shown, it cannot be listed or revoked, and it is valid until it expires or until\nthe secret that signed it is rotated out (see `SENSAPP_JWT_PREVIOUS_SECRETS`). Requires the\n`admin` scope. An `admin` token cannot make another `admin` token: those come from\n`sensapp generate-token`, which needs the secret. Not available when authentication is disabled.", + "operationId": "create_token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedToken" + } + } + }, + "description": "The token" + }, + "400": { + "description": "The request is not valid: empty subject, unknown scope, too long a duration" + }, + "401": { + "description": "Missing or invalid token" + }, + "403": { + "description": "The token does not have the admin scope, or the request asks for an admin token" + }, + "404": { + "description": "Authentication is disabled: there is nothing to sign a token with" + } + }, + "security": [ + { + "bearer": [ + "admin" + ] + } + ], + "summary": "Create a token", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/admin/vacuum": { + "post": { + "description": "Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle\nof a request leave some), then cleans up and optimizes the database and reclaims space, as far as\nthe storage backend supports it. Only exact duplicates go: two different values at the same\ntimestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list.", + "operationId": "vacuum_database", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VacuumResponse" + } + } + }, + "description": "Maintenance completed, with the number of duplicate samples removed" + }, + "403": { + "description": "The token does not have the delete scope, or has a sensor allow list" + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Failed to vacuum database" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_MAINTENANCE_TIMEOUT_SECONDS (one hour by default), and the storage backend may carry on after the answer" + } + }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], + "summary": "Database Vacuuming", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/prometheus_remote_read": { + "post": { + "description": "Allows you to read data from SensApp using Prometheus remote read protocol.\n\nIt follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/).", + "operationId": "prometheus_remote_read", + "parameters": [ + { + "description": "Content encoding, must be snappy", + "in": "header", + "name": "content-encoding", + "required": true, + "schema": { + "format": "snappy", + "type": "string" + } + }, + { + "description": "Content type, must be application/x-protobuf", + "in": "header", + "name": "content-type", + "required": true, + "schema": { + "format": "application/x-protobuf", + "type": "string" + } + }, + { + "description": "Prometheus Remote Read version, must be 0.1.0", + "in": "header", + "name": "x-prometheus-remote-read-version", + "required": true, + "schema": { + "format": "0.1.0", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": {} + }, + "description": "Prometheus Remote Read endpoint. [Reference](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/)" + }, + "responses": { + "200": { + "content": { + "application/x-protobuf": {} + }, + "description": "Read Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "Prometheus Remote Read API.", + "tags": [ + "Prometheus" + ] + } + }, + "/api/v1/prometheus_remote_write": { + "post": { + "description": "Allows you to write data from Prometheus to SensApp.\n\nIt follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/).", + "operationId": "publish_prometheus", + "parameters": [ + { + "description": "Content encoding, must be snappy", + "in": "header", + "name": "content-encoding", + "required": true, + "schema": { + "format": "snappy", + "type": "string" + } + }, + { + "description": "Content type, must be application/x-protobuf", + "in": "header", + "name": "content-type", + "required": true, + "schema": { + "format": "application/x-protobuf", + "type": "string" + } + }, + { + "description": "Prometheus Remote Write version, must be 0.1.0", + "in": "header", + "name": "x-prometheus-remote-write-version", + "required": true, + "schema": { + "format": "0.1.0", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": {} + }, + "description": "Prometheus Remote Write endpoint. [Reference](https://prometheus.io/docs/concepts/remote_write_spec/)" + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" + } + }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], + "summary": "Prometheus Remote Write API.", + "tags": [ + "Prometheus" + ] + } + }, + "/api/v1/query": { + "get": { + "description": "Parses a PromQL expression and returns matching time series data.\nOnly simple selectors (VectorSelector and MatrixSelector) are supported.\n\n# Examples\n\n- Simple metric: `GET /api/v1/query?query=my_metric`\n- With labels: `GET /api/v1/query?query=my_metric{env=\"prod\"}`\n- Range query: `GET /api/v1/query?query=my_metric[5m]`\n- With format: `GET /api/v1/query?query=my_metric&format=csv`", + "operationId": "simple_promql_query", + "parameters": [ + { + "description": "PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')", + "in": "query", + "name": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Output format: senml (default), csv, jsonl, or arrow", + "in": "query", + "name": "format", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Bucket width for aggregation queries, using Prometheus duration syntax (e.g., '5m'). Without it, the whole window is one bucket", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Query results in requested format" + }, + "400": { + "description": "Invalid or unsupported PromQL query" + }, + "500": { + "description": "Internal server error" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "Simple PromQL query endpoint.", + "tags": [ + "SensApp" + ] + } + }, + "/api/v2/write": { + "post": { + "description": "Allows you to write data from InfluxDB or Telegraf to SensApp.\n[More information.](https://github.com/SINTEF/sensapp/blob/main/docs/INFLUX_DB.md)", + "operationId": "publish_influxdb", + "parameters": [ + { + "description": "Bucket name", + "example": "sensapp", + "in": "query", + "name": "bucket", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Organization name", + "example": "sensapp", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Organization ID", + "in": "query", + "name": "org_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Precision of the timestamps. One of ns, us, ms, s", + "in": "query", + "name": "precision", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "text/plain": { + "example": "cpu,host=A,region=west usage_system=64.2 1590488773254420000", + "schema": { + "type": "string" + } + } + }, + "description": "InfluxDB Line Protocol endpoint. [Reference](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/).", + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" + } + }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], + "summary": "InfluxDB Compatible Write API.", + "tags": [ + "InfluxDB" + ] + } + }, + "/health/live": { + "get": { + "description": "Checks if the service is running.\nThis endpoint always returns 200 OK if the server is able to respond.", + "operationId": "liveness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Service is alive" + } + }, + "summary": "Liveness check", + "tags": [ + "Health" + ] + } + }, + "/health/ready": { + "get": { + "description": "Checks if the service is ready to accept traffic.\nThis includes checking if the database connection is working", + "operationId": "readiness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Service is ready" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Service is not ready" + } + }, + "summary": "Readiness check", + "tags": [ + "Health" + ] + } + }, + "/metrics": { + "get": { + "operationId": "list_metrics", + "parameters": [ + { + "description": "Filter metrics by name (substring match)", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter metrics by name using regex pattern", + "in": "query", + "name": "name_regex", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter metrics by sensor type (float, integer, string, boolean, location, json, blob, numeric)", + "in": "query", + "name": "type", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Metrics catalog in DCAT format" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "List unique metrics (measurement types) with aggregated information in DCAT catalog format.", + "tags": [ + "SensApp" + ] + } + }, + "/prometheus/metrics": { + "get": { + "operationId": "prometheus_metrics", + "parameters": [ + { + "description": "Append the most recent sample for Prometheus-compatible series", + "in": "query", + "name": "include_latest_samples", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Optional metric name filter used when include_latest_samples=true", + "in": "query", + "name": "metric", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional PromQL-style label selector used when include_latest_samples=true", + "in": "query", + "name": "selector", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Prometheus-compatible metrics" + } + }, + "security": [ + {}, + { + "bearer": [ + "read" + ] + } + ], + "tags": [ + "Observability" + ] + } + }, + "/publish": { + "post": { + "description": "Accepts sensor data in one of the following formats:\n- **SenML JSON** (RFC 8428): `Content-Type: application/json`\n- **CSV**: `Content-Type: text/csv` or `application/csv`\n- **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.stream`\n\nIf no Content-Type header is provided, defaults to CSV format.", + "operationId": "publish_sensors_data", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Sensor data in SenML JSON, CSV, or Apache Arrow format", + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Data ingested successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Bad Request - invalid data format" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "description": "The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay", + "headers": { + "Retry-After": { + "description": "Seconds to wait before sending the write again", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice" + } + }, + "security": [ + { + "bearer": [ + "write" + ] + } + ], + "summary": "SensApp native data ingestion API supporting multiple formats.", + "tags": [ + "SensApp" + ] + } + }, + "/series": { + "get": { + "operationId": "list_series", + "parameters": [ + { + "description": "Filter series by metric name", + "in": "query", + "name": "metric", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')", + "in": "query", + "name": "selector", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Series per page, 256 by default, 16384 at most", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page", + "in": "query", + "name": "bookmark", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "List all series (time series) in DCAT catalog format.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}": { + "delete": { + "description": "Publishing the same sensor again recreates it with the same UUID.\nRequires the `delete` scope when authentication is enabled.", + "operationId": "delete_series", + "parameters": [ + { + "description": "UUID of the series to delete", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Series deleted" + }, + "400": { + "description": "Invalid UUID" + }, + "403": { + "description": "The token does not have the delete scope" + }, + "404": { + "description": "Series not found" + }, + "501": { + "description": "The storage backend does not support deletion" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], + "summary": "Delete a series: all its samples, its labels and the sensor itself.", + "tags": [ + "Admin" + ] + }, + "get": { + "operationId": "get_series_data", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Output format: senml, csv, or jsonl (default: senml)", + "in": "query", + "name": "format", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Start datetime in ISO 8601 format (e.g., '2024-01-15T10:30:00Z')", + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "End datetime in ISO 8601 format (e.g., '2024-01-15T11:00:00Z')", + "in": "query", + "name": "end", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of samples (at most 100,000). Returns the oldest N samples of the window, in time order, and silently drops the rest. With simplify, it bounds the rows read before simplification", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Bucket width using Prometheus duration syntax (e.g., '1h', '5m', '1d')", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Aggregation function: avg, min, max, sum, count, first, last", + "in": "query", + "name": "aggregation", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicitly enable simplify-based point reduction. Applied to the rows read for the window (after bucketing when step is set); more than 100,000 rows to simplify is rejected with HTTP 400 instead of being truncated, so use step/aggregation or a narrower start/end", + "in": "query", + "name": "simplify", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "description": "Dimensionless simplify tolerance on normalized time/value coordinates", + "in": "query", + "name": "simplify_tolerance", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "description": "Use Douglas-Peucker only when simplifying", + "in": "query", + "name": "simplify_high_quality", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Series data in requested format" + }, + "400": { + "description": "Invalid format" + }, + "404": { + "description": "Series not found" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "Get series data in various formats based on query parameter.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/availability": { + "get": { + "operationId": "get_series_availability", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional bucket width using Prometheus duration syntax for coverage calculation", + "in": "query", + "name": "step", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Availability information for the requested series and window" + }, + "400": { + "description": "Invalid query parameters" + }, + "404": { + "description": "Series not found" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "Get presence and optional bucket coverage for a series over a time window.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/last": { + "get": { + "operationId": "get_series_last_sample", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Latest sample for the requested series" + }, + "400": { + "description": "Invalid query parameters" + }, + "404": { + "description": "Series not found or no sample matched the requested window" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "read" + ] + } + ], + "summary": "Get the most recent sample for a series, optionally within a bounded time window.", + "tags": [ + "SensApp" + ] + } + }, + "/series/{series_uuid}/samples": { + "delete": { + "description": "Both bounds are required, so that a request cannot wipe a whole series by\naccident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end`\ndeletes the samples at one exact timestamp. The sensor and its labels are kept.\nRequires the `delete` scope when authentication is enabled.", + "operationId": "delete_series_samples", + "parameters": [ + { + "description": "UUID of the series", + "in": "path", + "name": "series_uuid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive start datetime in ISO 8601 format", + "in": "query", + "name": "start", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive end datetime in ISO 8601 format", + "in": "query", + "name": "end", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Number of deleted samples" + }, + "400": { + "description": "Invalid UUID, or missing or invalid time bounds" + }, + "403": { + "description": "The token does not have the delete scope" + }, + "404": { + "description": "Series not found" + }, + "501": { + "description": "The storage backend does not support deletion" + }, + "503": { + "description": "The storage backend is unavailable: retry later" + }, + "504": { + "description": "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs" + } + }, + "security": [ + { + "bearer": [ + "delete" + ] + } + ], + "summary": "Delete the samples of a series between `start` and `end`, both inclusive.", + "tags": [ + "Admin" + ] + } + } + }, + "tags": [ + { + "description": "SensApp API", + "name": "SensApp" + }, + { + "description": "InfluxDB Write API", + "name": "InfluxDB" + }, + { + "description": "Prometheus-compatible service metrics", + "name": "Observability" + }, + { + "description": "Prometheus Remote Write and Read API", + "name": "Prometheus" + }, + { + "description": "Administrative operations", + "name": "Admin" + }, + { + "description": "Health check endpoints", + "name": "Health" + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46a89b38..e0f59a18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,13 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@fontsource/roboto": "^5.2.10", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/roboto": "^5.3.0", + "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", + "highlight.js": "^11.12.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -20,35 +22,27 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", - "@tanstack/react-query-devtools": "^5.91.3", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.2", - "daisyui": "^5.5.19", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^6.1.1", + "daisyui": "^5.7.47", + "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "jsdom": "^28.1.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", + "jsdom": "^30.1.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.2", + "vitest": "^5.0.3" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -57,34 +51,46 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { @@ -97,23 +103,6 @@ "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -397,17 +386,28 @@ "specificity": "bin/cli.js" } }, - "node_modules/@corvu/utils": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@corvu/utils/-/utils-0.4.2.tgz", - "integrity": "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.6.11" - }, - "peerDependencies": { - "solid-js": "^1.8" + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@csstools/color-helpers": { @@ -431,9 +431,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", - "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -450,14 +450,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", - "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -472,20 +472,20 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.2", - "@csstools/css-calc": "^3.4.1" + "@csstools/css-calc": "^3.4.3" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", - "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -506,9 +506,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", - "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -550,1773 +550,714 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource-variable/roboto": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz", + "integrity": "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource-variable/sora": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz", + "integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@hey-api/codegen-core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", + "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "c12": "3.3.4", + "color-support": "1.1.3" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=22.18.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", + "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", "dev": true, "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@jsdevtools/ono": "7.1.3", + "@types/json-schema": "7.0.15", + "js-yaml": "4.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" + "node": ">=22.18.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "node_modules/@hey-api/openapi-ts": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", + "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/shared": "0.5.0", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "@lukeed/ms": "2.0.2", + "ansi-colors": "4.1.3", + "color-support": "1.1.3", + "commander": "15.0.0", + "get-tsconfig": "4.14.0" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "bin": { + "openapi-ts": "bin/run.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=22.18.0" }, "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", - "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@fontsource/roboto": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz", - "integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@hey-api/codegen-core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", - "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/types": "0.1.4", - "ansi-colors": "4.1.3", - "c12": "3.3.4", - "color-support": "1.1.3" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", - "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "7.1.3", - "@types/json-schema": "7.0.15", - "js-yaml": "4.2.0" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/openapi-ts": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", - "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", - "@hey-api/shared": "0.5.0", - "@hey-api/spec-types": "0.2.0", - "@hey-api/types": "0.1.4", - "@lukeed/ms": "2.0.2", - "ansi-colors": "4.1.3", - "color-support": "1.1.3", - "commander": "15.0.0", - "get-tsconfig": "4.14.0" - }, - "bin": { - "openapi-ts": "bin/run.js" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" - } - }, - "node_modules/@hey-api/shared": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", - "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", - "@hey-api/spec-types": "0.2.0", - "@hey-api/types": "0.1.4", - "ansi-colors": "4.1.3", - "cross-spawn": "7.0.6", - "open": "11.0.0", - "semver": "7.8.4" - }, - "engines": { - "node": ">=22.18.0" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/spec-types": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", - "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hey-api/types": "0.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/hey-api" - } - }, - "node_modules/@hey-api/types": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", - "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@internationalized/number": { - "version": "3.6.8", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz", - "integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@kobalte/core": { - "version": "0.13.14", - "resolved": "https://registry.npmjs.org/@kobalte/core/-/core-0.13.14.tgz", - "integrity": "sha512-SBVB1lnvYbYt2x3t7fG0lo5fGLEbHQ6PII3NDPva8vD5e2v53On84ZVQiuIk0XI/55R4GQhden7Yem+R/XR8uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.5.1", - "@internationalized/number": "^3.2.1", - "@kobalte/utils": "^0.9.2", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/resize-observer": "^2.0.26", - "solid-presence": "^0.2.0", - "solid-prevent-scroll": "^0.1.11" - }, - "peerDependencies": { - "solid-js": "^1.9.8" - } - }, - "node_modules/@kobalte/utils": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@kobalte/utils/-/utils-0.9.2.tgz", - "integrity": "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.2.14", - "@solid-primitives/keyed": "^1.2.0", - "@solid-primitives/map": "^0.4.7", - "@solid-primitives/media": "^2.2.4", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/refs": "^1.0.5", - "@solid-primitives/utils": "^6.2.1" - }, - "peerDependencies": { - "solid-js": "^1.8.8" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", - "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", - "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", - "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", - "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", - "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", - "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", - "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", - "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", - "cpu": [ - "arm" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", - "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", - "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", - "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", - "cpu": [ - "loong64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", - "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", - "cpu": [ - "loong64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", - "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", - "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", - "cpu": [ - "ppc64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", - "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", - "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", - "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", - "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", - "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", - "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", - "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", - "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", - "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", - "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", - "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@solid-primitives/event-listener": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@solid-primitives/event-listener/-/event-listener-2.4.6.tgz", - "integrity": "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "url": "https://github.com/sponsors/hey-api" }, "peerDependencies": { - "solid-js": "^1.6.12" + "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" } }, - "node_modules/@solid-primitives/keyed": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@solid-primitives/keyed/-/keyed-1.5.3.tgz", - "integrity": "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA==", + "node_modules/@hey-api/shared": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", + "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", "dev": true, "license": "MIT", - "peerDependencies": { - "solid-js": "^1.6.12" + "dependencies": { + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "cross-spawn": "7.0.6", + "open": "11.0.0", + "semver": "7.8.4" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@solid-primitives/map": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@solid-primitives/map/-/map-0.4.13.tgz", - "integrity": "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==", + "node_modules/@hey-api/spec-types": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", + "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", "dev": true, "license": "MIT", "dependencies": { - "@solid-primitives/trigger": "^1.1.0" + "@hey-api/types": "0.1.4" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@solid-primitives/media": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@solid-primitives/media/-/media-2.3.6.tgz", - "integrity": "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw==", + "node_modules/@hey-api/types": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", + "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/static-store": "^0.1.4", - "@solid-primitives/utils": "^6.4.1" + "@humanfs/types": "^0.15.0" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/props": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/props/-/props-3.2.4.tgz", - "integrity": "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/refs": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/refs/-/refs-1.1.4.tgz", - "integrity": "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@solid-primitives/resize-observer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@solid-primitives/resize-observer/-/resize-observer-2.2.0.tgz", - "integrity": "sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/static-store": "^0.1.4", - "@solid-primitives/utils": "^6.4.1" + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@solid-primitives/rootless": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/rootless/-/rootless-1.5.4.tgz", - "integrity": "sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" }, - "peerDependencies": { - "solid-js": "^1.6.12" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@solid-primitives/static-store": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/static-store/-/static-store-0.1.4.tgz", - "integrity": "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==", - "dev": true, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@solid-primitives/storage": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-1.3.11.tgz", - "integrity": "sha512-PpQWR3TaTxHIJFbI9ZssYTM4Aa67g1vJIgps4TPhcXzHqqomrPAIveFC2FG7SDQoi9YQia8FVBjigELziJpfIg==", - "dev": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.2.0" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@solid-primitives/transition-group": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@solid-primitives/transition-group/-/transition-group-1.1.2.tgz", - "integrity": "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==", - "dev": true, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", - "peerDependencies": { - "solid-js": "^1.6.12" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@solid-primitives/trigger": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@solid-primitives/trigger/-/trigger-1.2.4.tgz", - "integrity": "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ==", - "dev": true, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@solid-primitives/utils": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.4.1.tgz", - "integrity": "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==", + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", "dev": true, "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, "peerDependencies": { - "solid-js": "^1.6.12" + "keyv": "^5.6.0" } }, - "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==", + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", - "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.28" - }, + "license": "MIT", "engines": { - "node": ">=10" - }, + "node": ">=8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.16.2", - "@swc/core-darwin-x64": "1.16.2", - "@swc/core-linux-arm-gnueabihf": "1.16.2", - "@swc/core-linux-arm64-gnu": "1.16.2", - "@swc/core-linux-arm64-musl": "1.16.2", - "@swc/core-linux-ppc64-gnu": "1.16.2", - "@swc/core-linux-s390x-gnu": "1.16.2", - "@swc/core-linux-x64-gnu": "1.16.2", - "@swc/core-linux-x64-musl": "1.16.2", - "@swc/core-win32-arm64-msvc": "1.16.2", - "@swc/core-win32-ia32-msvc": "1.16.2", - "@swc/core-win32-x64-msvc": "1.16.2" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", - "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", - "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", - "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", "cpu": [ "arm" ], - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", - "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", - "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", - "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", - "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", - "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", - "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", - "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "win32" + "openharmony" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", - "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", "cpu": [ - "ia32" + "arm64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", - "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", "cpu": [ "x64" ], - "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/types": { - "version": "0.1.28", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", - "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" }, "node_modules/@tailwindcss/node": { "version": "4.3.3", @@ -2587,89 +1528,29 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tanstack/match-sorter-utils": { - "version": "8.19.4", - "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", - "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-accents": "0.5.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/query-core": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz", - "integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-devtools": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.103.2.tgz", - "integrity": "sha512-QW+uVtT7dZcc9KMmKQ846Yw25h7WPhM1cfCYYaSkkmPqkK39g+Cj1tcAt+Ege+6rBVFQvNyYln0FgAAHcYGSdA==", - "dev": true, + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", "license": "MIT", - "dependencies": { - "@kobalte/core": "^0.13.4", - "@solid-primitives/keyed": "^1.2.2", - "@solid-primitives/resize-observer": "^2.0.26", - "@solid-primitives/storage": "^1.3.11", - "@tanstack/match-sorter-utils": "^8.19.4", - "@tanstack/query-core": "5.103.2", - "clsx": "^2.1.1", - "goober": "^2.1.16", - "solid-js": "^1.9.7", - "solid-transition-group": "^0.2.3", - "superjson": "^2.2.2" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz", - "integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.103.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tanstack/react-query-devtools": { - "version": "5.103.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.103.2.tgz", - "integrity": "sha512-NV2AwKeS4UoyAJFcU0CNsoABKVf3A9KMX2InuII2LPUW7z6U296mbQITURAoMwDAxC2AMFCbXlMEPCgRGveQhw==", - "dev": true, + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.103.2" + "@tanstack/query-core": "5.104.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.103.2", - "@types/react": "^18 || ^19", "react": "^18 || ^19" } }, @@ -2695,9 +1576,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2709,9 +1590,18 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -2789,10 +1679,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2803,13 +1701,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", - "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { @@ -2833,17 +1731,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", - "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2856,15 +1754,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2872,16 +1770,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", - "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -2897,14 +1795,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", - "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -2919,14 +1817,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", - "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2937,9 +1835,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", - "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -2954,15 +1852,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", - "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2979,9 +1877,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", - "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -2993,16 +1891,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", - "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3020,56 +1918,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", - "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3084,13 +1943,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", - "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3101,64 +1960,47 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.3.tgz", - "integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1", - "@swc/core": "^1.15.46" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3176,70 +2018,22 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -3267,16 +2061,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3316,16 +2100,14 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "peer": true, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3359,16 +2141,19 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.26", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3389,20 +2174,22 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.1.tgz", - "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3420,9 +2207,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.25", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.438", + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, @@ -3434,9 +2221,9 @@ } }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3478,20 +2265,24 @@ } } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001812", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", - "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -3510,32 +2301,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3552,36 +2326,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -3602,13 +2346,6 @@ "node": ">=22.12.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3636,19 +2373,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/copy-anything": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.1.tgz", - "integrity": "sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3672,44 +2396,18 @@ "license": "MIT", "dependencies": { "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=20" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", @@ -3719,9 +2417,9 @@ "license": "MIT" }, "node_modules/daisyui": { - "version": "5.7.46", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.46.tgz", - "integrity": "sha512-z/tfVXLpLRe1ojGeokzxauHA9Ij5CGfk+83EdatQbFSVr02h5mUcp4nkgQJOw68wH1wAAPu/SY+MWbDdJG2R4w==", + "version": "5.7.47", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.47.tgz", + "integrity": "sha512-NOl4vPifelRqB3CMzoU751zc44KCDD9HdHMTTqQ30uiYVpIyK7+s94/ySDWgYqjdl1tQCaCrgGBx4KCizm+a3Q==", "dev": true, "license": "MIT", "funding": { @@ -3729,17 +2427,17 @@ } }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/debug": { @@ -3881,20 +2579,6 @@ "zrender": "6.1.0" } }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "size-sensor": "^1.0.1" - }, - "peerDependencies": { - "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "react": "^15.0.0 || >=16.0.0" - } - }, "node_modules/echarts/node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -3902,16 +2586,16 @@ "license": "0BSD" }, "node_modules/electron-to-chromium": { - "version": "1.5.439", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", - "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", - "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3941,47 +2625,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4006,45 +2649,43 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4052,7 +2693,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4087,58 +2728,60 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4211,9 +2854,9 @@ } }, "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "dev": true, "license": "MIT" }, @@ -4221,6 +2864,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4255,16 +2899,13 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -4285,17 +2926,15 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -4366,9 +3005,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -4378,30 +3017,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/goober": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", - "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/hermes-estree": { @@ -4421,45 +3053,33 @@ "hermes-estree": "0.25.1" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=12.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } + "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": ">= 14" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/ignore": { @@ -4472,23 +3092,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4650,39 +3253,38 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -4690,6 +3292,16 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4703,13 +3315,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4738,13 +3343,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -5038,13 +3643,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5093,16 +3691,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -5239,19 +3840,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -5403,20 +3991,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5427,6 +4001,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/rc9": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", @@ -5533,13 +4127,6 @@ "node": ">=8" } }, - "node_modules/remove-accents": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", - "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", - "dev": true, - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5550,16 +4137,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -5570,49 +4147,37 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.63.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", - "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.5", - "@rollup/rollup-android-arm64": "4.63.5", - "@rollup/rollup-darwin-arm64": "4.63.5", - "@rollup/rollup-darwin-x64": "4.63.5", - "@rollup/rollup-freebsd-arm64": "4.63.5", - "@rollup/rollup-freebsd-x64": "4.63.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", - "@rollup/rollup-linux-arm-musleabihf": "4.63.5", - "@rollup/rollup-linux-arm64-gnu": "4.63.5", - "@rollup/rollup-linux-arm64-musl": "4.63.5", - "@rollup/rollup-linux-loong64-gnu": "4.63.5", - "@rollup/rollup-linux-loong64-musl": "4.63.5", - "@rollup/rollup-linux-ppc64-gnu": "4.63.5", - "@rollup/rollup-linux-ppc64-musl": "4.63.5", - "@rollup/rollup-linux-riscv64-gnu": "4.63.5", - "@rollup/rollup-linux-riscv64-musl": "4.63.5", - "@rollup/rollup-linux-s390x-gnu": "4.63.5", - "@rollup/rollup-linux-x64-gnu": "4.63.5", - "@rollup/rollup-linux-x64-musl": "4.63.5", - "@rollup/rollup-openbsd-x64": "4.63.5", - "@rollup/rollup-openharmony-arm64": "4.63.5", - "@rollup/rollup-win32-arm64-msvc": "4.63.5", - "@rollup/rollup-win32-ia32-msvc": "4.63.5", - "@rollup/rollup-win32-x64-gnu": "4.63.5", - "@rollup/rollup-win32-x64-msvc": "4.63.5", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, "node_modules/run-applescript": { @@ -5660,29 +4225,6 @@ "node": ">=10" } }, - "node_modules/seroval": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", - "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", - "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -5712,157 +4254,35 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/size-sensor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, - "node_modules/solid-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz", - "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.1.0", - "seroval": "~1.5.4", - "seroval-plugins": "~1.5.4" - } - }, - "node_modules/solid-presence": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/solid-presence/-/solid-presence-0.2.0.tgz", - "integrity": "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@corvu/utils": "~0.4.2" - }, - "peerDependencies": { - "solid-js": "^1.8" - } - }, - "node_modules/solid-prevent-scroll": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/solid-prevent-scroll/-/solid-prevent-scroll-0.1.11.tgz", - "integrity": "sha512-2PComVCDHaQN/5t7ogEqUYeHasritZKXZ8Sb/VLkl0Web8o9YhEwOo8LSujJEahvMlYCNyKt0zRJeHldOAeWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@corvu/utils": "~0.4.2" - }, - "peerDependencies": { - "solid-js": "^1.8" - } - }, - "node_modules/solid-transition-group": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/solid-transition-group/-/solid-transition-group-0.2.3.tgz", - "integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solid-primitives/refs": "^1.0.5", - "@solid-primitives/transition-group": "^1.0.2" - }, - "engines": { - "node": ">=18.0.0", - "pnpm": ">=8.6.0" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } + "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "min-indent": "^1.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -5883,11 +4303,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.1", @@ -5915,33 +4338,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { - "version": "7.4.15", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", - "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.15" + "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.15", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", - "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", "dev": true, "license": "MIT" }, @@ -5959,16 +4372,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/ts-api-utils": { @@ -5988,8 +4401,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, - "license": "0BSD" + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -6019,16 +4432,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", - "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6043,19 +4456,19 @@ } }, "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "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==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, @@ -6101,17 +4514,16 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6127,9 +4539,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.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", @@ -6142,13 +4555,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -6174,39 +4590,293 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6214,16 +4884,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -6264,17 +4934,27 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -6298,18 +4978,18 @@ } }, "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/which": { @@ -6329,20 +5009,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { diff --git a/frontend/package.json b/frontend/package.json index fb147116..4f431976 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,13 @@ "openapi-ts": "openapi-ts" }, "dependencies": { - "@fontsource/roboto": "^5.2.10", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/roboto": "^5.3.0", + "@fontsource-variable/sora": "^5.3.0", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.104.1", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.6", + "highlight.js": "^11.12.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -26,26 +28,25 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", - "@tanstack/react-query-devtools": "^5.91.3", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.2.2", - "daisyui": "^5.5.19", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^6.1.1", + "daisyui": "^5.7.47", + "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "jsdom": "^28.1.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", + "jsdom": "^30.1.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.2", + "vitest": "^5.0.3" }, "overrides": { "@hey-api/json-schema-ref-parser": { diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..14d3d27b Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/sensapp_logo-fs8.png b/frontend/public/sensapp_logo-fs8.png new file mode 100644 index 00000000..594539a0 Binary files /dev/null and b/frontend/public/sensapp_logo-fs8.png differ diff --git a/frontend/public/sensapp_logo.png b/frontend/public/sensapp_logo.png new file mode 100644 index 00000000..6ae006ab Binary files /dev/null and b/frontend/public/sensapp_logo.png differ diff --git a/frontend/public/sensapp_logo_small-fs8.png b/frontend/public/sensapp_logo_small-fs8.png new file mode 100644 index 00000000..f332db95 Binary files /dev/null and b/frontend/public/sensapp_logo_small-fs8.png differ diff --git a/frontend/public/sensapp_logo_small.png b/frontend/public/sensapp_logo_small.png new file mode 100644 index 00000000..355e5eba Binary files /dev/null and b/frontend/public/sensapp_logo_small.png differ diff --git a/frontend/public/sensapp_logo_small_white-fs8.png b/frontend/public/sensapp_logo_small_white-fs8.png new file mode 100644 index 00000000..2f320cbc Binary files /dev/null and b/frontend/public/sensapp_logo_small_white-fs8.png differ diff --git a/frontend/public/sensapp_logo_small_white.png b/frontend/public/sensapp_logo_small_white.png new file mode 100644 index 00000000..cd732283 Binary files /dev/null and b/frontend/public/sensapp_logo_small_white.png differ diff --git a/frontend/public/sensapp_logo_white-fs8.png b/frontend/public/sensapp_logo_white-fs8.png new file mode 100644 index 00000000..581732be Binary files /dev/null and b/frontend/public/sensapp_logo_white-fs8.png differ diff --git a/frontend/public/sensapp_logo_white.png b/frontend/public/sensapp_logo_white.png new file mode 100644 index 00000000..64f0927e Binary files /dev/null and b/frontend/public/sensapp_logo_white.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0e1bb62c..3f2c1a03 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -10,13 +10,11 @@ import { useSelectionStore } from './stores/useSelectionStore'; // Mock the API client with controllable responses const mockListMetrics = vi.fn(); const mockListSeries = vi.fn(); -const mockReadiness = vi.fn(); const mockGetSeriesData = vi.fn(); vi.mock('./client', () => ({ listMetrics: (...args: unknown[]) => mockListMetrics(...args), listSeries: (...args: unknown[]) => mockListSeries(...args), - readiness: (...args: unknown[]) => mockReadiness(...args), getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), })); @@ -84,7 +82,6 @@ describe('App integration', () => { vi.clearAllMocks(); mockListMetrics.mockResolvedValue({ data: catalogWithMetrics }); mockListSeries.mockResolvedValue({ data: seriesCatalog }); - mockReadiness.mockResolvedValue({ data: { status: 'ready', database: 'sqlite' } }); mockGetSeriesData.mockResolvedValue({ data: [] }); useSelectionStore.setState({ selectedMetric: null, @@ -93,11 +90,10 @@ describe('App integration', () => { }); }); - it('renders the header with logo, health badge, and docs link', async () => { + it('renders the header with the name and the docs link', async () => { render(, { wrapper: createWrapper() }); - expect(screen.getByText('SensApp')).toBeInTheDocument(); - expect(await screen.findByText('Connected')).toBeInTheDocument(); + expect(screen.getByAltText('SensApp')).toBeInTheDocument(); const docsLink = screen.getByText('API Docs').closest('a'); expect(docsLink).toHaveAttribute('href', '/docs'); @@ -139,7 +135,7 @@ describe('App integration', () => { it('renders the application shell', () => { render(, { wrapper: createWrapper() }); - expect(screen.getByText('SensApp')).toBeInTheDocument(); - expect(screen.getByText('Sensor Data Explorer')).toBeInTheDocument(); + expect(screen.getByAltText('SensApp')).toBeInTheDocument(); + expect(screen.getByText('Data Explorer')).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ff0085b..bd0de9e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,34 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; import { Layout } from './components/Layout'; import { ExplorerPage } from './pages/ExplorerPage'; +// The page that explains how to load data brings the code highlighter: it is fetched when the tab is first opened +const LoadPage = lazy(() => import('./pages/LoadPage')); +// So does the one that makes tokens, which shows them as code +const CredentialsPage = lazy(() => import('./pages/CredentialsPage')); + function App() { return ( }> } /> + + + + } + /> + + + + } + /> ); diff --git a/frontend/src/api/clientConfig.test.ts b/frontend/src/api/clientConfig.test.ts index d5853b9c..d4f036eb 100644 --- a/frontend/src/api/clientConfig.test.ts +++ b/frontend/src/api/clientConfig.test.ts @@ -1,5 +1,77 @@ -import { describe, it, expect } from 'vitest'; -import { extractErrorMessage } from './clientConfig'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { client } from '../client/client.gen'; +import { listMetrics } from '../client'; +import { ApiError, configureApiClient, extractErrorMessage, unwrap } from './clientConfig'; +import { useAuthStore } from '../stores/useAuthStore'; + +describe('ApiError and unwrap', () => { + it('returns the data of a successful call', () => { + expect(unwrap({ data: { ok: true } })).toEqual({ ok: true }); + }); + + it('throws an ApiError with the status and the message of the server', () => { + try { + unwrap({ error: 'Invalid token: ExpiredSignature', response: { status: 401 } }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toBe('Invalid token: ExpiredSignature'); + expect((error as ApiError).status).toBe(401); + expect((error as ApiError).isAuthError).toBe(true); + } + }); + + it('knows which errors are about authentication', () => { + expect(new ApiError('no', 401).isAuthError).toBe(true); + expect(new ApiError('no', 403).isAuthError).toBe(true); + expect(new ApiError('no', 400).isAuthError).toBe(false); + expect(new ApiError('no', 500).isAuthError).toBe(false); + expect(new ApiError('no').isAuthError).toBe(false); + }); +}); + +describe('the configured API client', () => { + let authorization: string | null | undefined; + let status = 200; + + beforeAll(() => { + configureApiClient(); + client.setConfig({ + baseUrl: 'http://example.test', + fetch: async (input) => { + authorization = (input as Request).headers.get('Authorization'); + return status === 200 + ? new Response('{}', { headers: { 'Content-Type': 'application/json' } }) + : new Response('Invalid token: ExpiredSignature', { status }); + }, + }); + }); + + beforeEach(() => { + authorization = undefined; + status = 200; + useAuthStore.setState({ token: null }); + }); + + it('sends no Authorization header without a token', async () => { + await listMetrics(); + expect(authorization).toBeNull(); + }); + + it('sends the token as a Bearer token', async () => { + useAuthStore.getState().signIn('the.jwt.token'); + await listMetrics(); + expect(authorization).toBe('Bearer the.jwt.token'); + }); + + it('turns a refused request into an authentication error', async () => { + status = 401; + const result = await listMetrics(); + expect(() => unwrap(result)).toThrow( + expect.objectContaining({ status: 401, message: 'Invalid token: ExpiredSignature' }), + ); + }); +}); describe('extractErrorMessage', () => { it('extracts message from Error objects', () => { diff --git a/frontend/src/api/clientConfig.ts b/frontend/src/api/clientConfig.ts index b15dbd69..225df495 100644 --- a/frontend/src/api/clientConfig.ts +++ b/frontend/src/api/clientConfig.ts @@ -1,14 +1,52 @@ import { client } from '../client/client.gen'; +import { useAuthStore } from '../stores/useAuthStore'; export function configureApiClient() { const baseUrl = import.meta.env.VITE_SENSAPP_API_URL || ''; client.setConfig({ baseUrl }); + client.interceptors.request.use((request) => { + const { token } = useAuthStore.getState(); + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + return request; + }); +} + +/** An error answer of the API. 401 and 403 mean the token is missing, wrong or not enough. */ +export class ApiError extends Error { + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } + + get isAuthError(): boolean { + return this.status === 401 || this.status === 403; + } +} + +/** + * The data of a generated client call, or an `ApiError` carrying the status and the message of + * the server. The client returns errors instead of throwing them; React Query needs a throw. + */ +export function unwrap(result: { + data?: T; + error?: unknown; + response?: { status: number }; +}): T { + if (result.error) { + throw new ApiError(extractErrorMessage(result.error), result.response?.status); + } + return result.data as T; } /** * Extract a human-readable error message from API errors. - * hey-api/client-fetch throws raw response data on throwOnError, - * which may be an object, string, or Error. + * hey-api/client-fetch returns the body of an error answer: parsed JSON, or the plain text + * (which is what 401 and 403 are), or `{}` for an empty body. */ export function extractErrorMessage(error: unknown): string { if (error instanceof Error) { diff --git a/frontend/src/api/queryClient.ts b/frontend/src/api/queryClient.ts new file mode 100644 index 00000000..c87f98d1 --- /dev/null +++ b/frontend/src/api/queryClient.ts @@ -0,0 +1,26 @@ +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; +import { ApiError } from './clientConfig'; +import { useAuthStore } from '../stores/useAuthStore'; + +/** The server refused the request: ask for a token instead of showing an error alone. */ +function askForTokenWhenRefused(error: unknown) { + if (error instanceof ApiError && error.isAuthError) { + useAuthStore.getState().reject(error.message); + } +} + +export function createQueryClient() { + return new QueryClient({ + queryCache: new QueryCache({ onError: askForTokenWhenRefused }), + // Making a token is a mutation, and refused like a read when the token is not an admin's + mutationCache: new MutationCache({ onError: askForTokenWhenRefused }), + defaultOptions: { + queries: { + staleTime: 30_000, + // Asking again does not make a token valid + retry: (failureCount, error) => + !(error instanceof ApiError && error.isAuthError) && failureCount < 1, + }, + }, + }); +} diff --git a/frontend/src/auth.test.tsx b/frontend/src/auth.test.tsx new file mode 100644 index 00000000..3774b443 --- /dev/null +++ b/frontend/src/auth.test.tsx @@ -0,0 +1,168 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import App from './App'; +import { createQueryClient } from './api/queryClient'; +import { useAuthStore } from './stores/useAuthStore'; +import { useSelectionStore } from './stores/useSelectionStore'; +import { fakeToken } from './test/fakeToken'; + +// A server that wants a token: the generated client answers like it does on an HTTP error, with +// the JSON body SensApp sends, {"error": "…"} +const mockListMetrics = vi.fn(); +vi.mock('./client', () => ({ + listMetrics: (...args: unknown[]) => mockListMetrics(...args), + listSeries: vi.fn().mockResolvedValue({ data: { 'dcat:dataset': [] } }), + getSeriesData: vi.fn().mockResolvedValue({ data: [] }), +})); + +const GOOD_TOKEN = fakeToken({ sub: 'alice', scope: 'read', exp: 4_102_444_800 }); + +const catalog = { + '@type': 'dcat:Catalog', + 'dcat:dataset': [ + { + '@type': 'dcat:Dataset', + '@id': 'temperature', + 'dct:identifier': 'metric:temperature', + 'dct:title': 'temperature', + 'sensor:type': 'float', + 'sensor:seriesCount': 2, + 'dcat:distribution': [], + }, + ], +}; + +let calls = 0; + +/** Answers the catalog to the good token, and what SensApp answers to anything else. */ +function sensappWithJwt(token: string | null) { + calls++; + if (token === GOOD_TOKEN) return { data: catalog }; + if (token === 'no-read-scope') { + return { error: { error: 'Read access required' }, response: { status: 403 } }; + } + return token + ? { error: { error: 'Invalid token: ExpiredSignature' }, response: { status: 401 } } + : { error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }; +} + +function renderApp() { + const queryClient = createQueryClient(); + // No pause between the asks, and no retry of a failure that is not about authentication + queryClient.setDefaultOptions({ queries: { staleTime: 30_000, retry: false } }); + const view = render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + return { ...view, queryClient }; +} + +describe('authentication', () => { + beforeEach(() => { + calls = 0; + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + useSelectionStore.setState({ selectedMetric: null, selectedSeries: [], labelFilter: '' }); + mockListMetrics.mockImplementation(() => Promise.resolve(sensappWithJwt(useAuthStore.getState().token))); + }); + + it('asks for a token when the server answers 401, then shows the data', async () => { + const user = userEvent.setup(); + renderApp(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Authentication required'); + expect(dialog).toHaveTextContent('Missing or invalid Authorization header'); + expect(dialog).toHaveTextContent('sensapp generate-token'); + expect(screen.getByRole('button', { name: 'Use token' })).toBeDisabled(); + + await user.type(screen.getByLabelText('JWT'), `Bearer ${GOOD_TOKEN}`); + await user.click(screen.getByRole('button', { name: 'Use token' })); + + // The refused request is asked again with the token + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(useAuthStore.getState().token).toBe(GOOD_TOKEN); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + it('does not ask again and again for what the server refuses', async () => { + renderApp(); + await screen.findByRole('dialog'); + // One request: a refusal is not retried + expect(calls).toBe(1); + }); + + it('says why a token was not accepted, and asks for another', async () => { + const user = userEvent.setup(); + renderApp(); + await screen.findByRole('dialog'); + + await user.type(screen.getByLabelText('JWT'), 'an.expired.token'); + await user.click(screen.getByRole('button', { name: 'Use token' })); + + const dialog = await screen.findByText('This token is not accepted'); + expect(dialog.closest('[role="dialog"]')).toHaveTextContent('Invalid token: ExpiredSignature'); + + await user.type(screen.getByLabelText('JWT'), GOOD_TOKEN); + await user.click(screen.getByRole('button', { name: 'Use token' })); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + }); + + it('asks for another token when the one it has is not enough (403)', async () => { + sessionStorage.setItem('sensapp-auth', JSON.stringify({ state: { token: 'no-read-scope' }, version: 0 })); + await useAuthStore.persist.rehydrate(); + renderApp(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Read access required'); + }); + + it('can be dismissed, and reopened with Sign in', async () => { + const user = userEvent.setup(); + renderApp(); + await screen.findByRole('dialog'); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // The error stays where the data should be + expect(await screen.findByText(/Failed to load metrics/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Sign in' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('signs out: forgets the token and the data, and asks again', async () => { + const user = userEvent.setup(); + useAuthStore.getState().signIn(GOOD_TOKEN); + const { queryClient } = renderApp(); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + expect(useAuthStore.getState().token).toBeNull(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(queryClient.getQueryData(['metrics', { name: undefined, type: undefined }])).toBeUndefined(); + expect(screen.queryByText('temperature')).not.toBeInTheDocument(); + }); + + it('works without any token when the server does not ask for one', async () => { + mockListMetrics.mockResolvedValue({ data: catalog }); + renderApp(); + expect(await screen.findByText('temperature')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index b6fc2a67..cf51d1f0 100644 --- a/frontend/src/client/@tanstack/react-query.gen.ts +++ b/frontend/src/client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { frontpage, getSeriesData, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from '../sdk.gen'; -import type { FrontpageData, FrontpageResponse, GetSeriesDataData, ListMetricsData, ListSeriesData, LivenessData, LivenessResponse, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusRemoteReadData, PrometheusRemoteReadError, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbResponse, PublishPrometheusData, PublishPrometheusError, PublishPrometheusResponse, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataResponse, ReadinessData, ReadinessError, ReadinessResponse2, SimplePromqlQueryData, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseResponse } from '../types.gen'; +import { createToken, deleteSeries, deleteSeriesSamples, frontpage, getSeriesAvailability, getSeriesData, getSeriesLastSample, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from '../sdk.gen'; +import type { CreateTokenData, CreateTokenResponse, DeleteSeriesData, DeleteSeriesResponse, DeleteSeriesSamplesData, FrontpageData, FrontpageResponse, GetSeriesAvailabilityData, GetSeriesDataData, GetSeriesLastSampleData, ListMetricsData, ListSeriesData, LivenessData, LivenessResponse, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusRemoteReadData, PrometheusRemoteReadError, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbResponse, PublishPrometheusData, PublishPrometheusError, PublishPrometheusResponse, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataResponse, ReadinessData, ReadinessError, ReadinessResponse2, SimplePromqlQueryData, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -54,11 +54,36 @@ export const frontpageOptions = (options?: Options) => queryOptio queryKey: frontpageQueryKey(options) }); +/** + * Create a token + * + * Makes a signed token for a client. SensApp does not keep tokens: the answer is the only time + * the token is shown, it cannot be listed or revoked, and it is valid until it expires or until + * the secret that signed it is rotated out (see `SENSAPP_JWT_PREVIOUS_SECRETS`). Requires the + * `admin` scope. An `admin` token cannot make another `admin` token: those come from + * `sensapp generate-token`, which needs the secret. Not available when authentication is disabled. + */ +export const createTokenMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await createToken({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + /** * Database Vacuuming * - * Cleans up and optimizes the database by removing unused data and reclaiming space. - * (only if supported by the underlying storage engine). + * Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle + * of a request leave some), then cleans up and optimizes the database and reclaims space, as far as + * the storage backend supports it. Only exact duplicates go: two different values at the same + * timestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list. */ export const vacuumDatabaseMutation = (options?: Partial>): UseMutationOptions> => { const mutationOptions: UseMutationOptions> = { @@ -245,7 +270,7 @@ export const prometheusMetricsOptions = (options?: Options) => queryOpt queryKey: listSeriesQueryKey(options) }); +/** + * Delete a series: all its samples, its labels and the sensor itself. + * + * Publishing the same sensor again recreates it with the same UUID. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSeries({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSeriesDataQueryKey = (options: Options) => createQueryKey('getSeriesData', options); /** @@ -357,3 +402,121 @@ export const getSeriesDataInfiniteOptions = (options: Options }); return opts as Omit; }; + +export const getSeriesAvailabilityQueryKey = (options: Options) => createQueryKey('getSeriesAvailability', options); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailabilityOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSeriesAvailability({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesAvailabilityQueryKey(options) +}); + +export const getSeriesAvailabilityInfiniteQueryKey = (options: Options): QueryKey> => createQueryKey('getSeriesAvailability', options, true); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailabilityInfiniteOptions = (options: Options) => { + const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { + query: { + start: pageParam + } + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSeriesAvailability({ + ...options, + ...params, + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesAvailabilityInfiniteQueryKey(options) + }); + return opts as Omit; +}; + +export const getSeriesLastSampleQueryKey = (options: Options) => createQueryKey('getSeriesLastSample', options); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSampleOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSeriesLastSample({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesLastSampleQueryKey(options) +}); + +export const getSeriesLastSampleInfiniteQueryKey = (options: Options): QueryKey> => createQueryKey('getSeriesLastSample', options, true); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSampleInfiniteOptions = (options: Options) => { + const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { + query: { + start: pageParam + } + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSeriesLastSample({ + ...options, + ...params, + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSeriesLastSampleInfiniteQueryKey(options) + }); + return opts as Omit; +}; + +/** + * Delete the samples of a series between `start` and `end`, both inclusive. + * + * Both bounds are required, so that a request cannot wipe a whole series by + * accident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end` + * deletes the samples at one exact timestamp. The sensor and its labels are kept. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesSamplesMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSeriesSamples({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index ac5804b9..de6fba5c 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { frontpage, getSeriesData, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from './sdk.gen'; -export type { AppError, ClientOptions, FrontpageData, FrontpageResponse, FrontpageResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, HealthResponse, ListMetricsData, ListMetricsResponses, ListSeriesData, ListSeriesResponses, LivenessData, LivenessResponse, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadError, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbErrors, PublishInfluxdbResponse, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusError, PublishPrometheusErrors, PublishPrometheusResponse, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataErrors, PublishSensorsDataResponse, PublishSensorsDataResponses, ReadinessData, ReadinessError, ReadinessErrors, ReadinessResponse, ReadinessResponse2, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseErrors, VacuumDatabaseResponse, VacuumDatabaseResponses } from './types.gen'; +export { createToken, deleteSeries, deleteSeriesSamples, frontpage, getSeriesAvailability, getSeriesData, getSeriesLastSample, listMetrics, listSeries, liveness, type Options, prometheusMetrics, prometheusRemoteRead, publishInfluxdb, publishPrometheus, publishSensorsData, readiness, simplePromqlQuery, vacuumDatabase } from './sdk.gen'; +export type { AppError, ClientOptions, CreatedToken, CreateTokenData, CreateTokenErrors, CreateTokenRequest, CreateTokenResponse, CreateTokenResponses, DeleteSeriesData, DeleteSeriesErrors, DeleteSeriesResponse, DeleteSeriesResponses, DeleteSeriesSamplesData, DeleteSeriesSamplesErrors, DeleteSeriesSamplesResponses, FrontpageData, FrontpageResponse, FrontpageResponses, GetSeriesAvailabilityData, GetSeriesAvailabilityErrors, GetSeriesAvailabilityResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, GetSeriesLastSampleData, GetSeriesLastSampleErrors, GetSeriesLastSampleResponses, HealthResponse, ListMetricsData, ListMetricsErrors, ListMetricsResponses, ListSeriesData, ListSeriesErrors, ListSeriesResponses, LivenessData, LivenessResponse, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponse, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadError, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbError, PublishInfluxdbErrors, PublishInfluxdbResponse, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusError, PublishPrometheusErrors, PublishPrometheusResponse, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataError, PublishSensorsDataErrors, PublishSensorsDataResponse, PublishSensorsDataResponses, ReadinessData, ReadinessError, ReadinessErrors, ReadinessResponse, ReadinessResponse2, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseError, VacuumDatabaseErrors, VacuumDatabaseResponse, VacuumDatabaseResponses, VacuumResponse } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 0b059f90..bb528e5c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { FrontpageData, FrontpageResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, ListMetricsData, ListMetricsResponses, ListSeriesData, ListSeriesResponses, LivenessData, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbErrors, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusErrors, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataErrors, PublishSensorsDataResponses, ReadinessData, ReadinessErrors, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseErrors, VacuumDatabaseResponses } from './types.gen'; +import type { CreateTokenData, CreateTokenErrors, CreateTokenResponses, DeleteSeriesData, DeleteSeriesErrors, DeleteSeriesResponses, DeleteSeriesSamplesData, DeleteSeriesSamplesErrors, DeleteSeriesSamplesResponses, FrontpageData, FrontpageResponses, GetSeriesAvailabilityData, GetSeriesAvailabilityErrors, GetSeriesAvailabilityResponses, GetSeriesDataData, GetSeriesDataErrors, GetSeriesDataResponses, GetSeriesLastSampleData, GetSeriesLastSampleErrors, GetSeriesLastSampleResponses, ListMetricsData, ListMetricsErrors, ListMetricsResponses, ListSeriesData, ListSeriesErrors, ListSeriesResponses, LivenessData, LivenessResponses, PrometheusMetricsData, PrometheusMetricsResponses, PrometheusRemoteReadData, PrometheusRemoteReadErrors, PrometheusRemoteReadResponses, PublishInfluxdbData, PublishInfluxdbErrors, PublishInfluxdbResponses, PublishPrometheusData, PublishPrometheusErrors, PublishPrometheusResponses, PublishSensorsDataData, PublishSensorsDataErrors, PublishSensorsDataResponses, ReadinessData, ReadinessErrors, ReadinessResponses, SimplePromqlQueryData, SimplePromqlQueryErrors, SimplePromqlQueryResponses, VacuumDatabaseData, VacuumDatabaseErrors, VacuumDatabaseResponses } from './types.gen'; export type Options = Options2 & { /** @@ -20,13 +20,38 @@ export type Options(options?: Options): RequestResult => (options?.client ?? client).get({ url: '/', ...options }); +/** + * Create a token + * + * Makes a signed token for a client. SensApp does not keep tokens: the answer is the only time + * the token is shown, it cannot be listed or revoked, and it is valid until it expires or until + * the secret that signed it is rotated out (see `SENSAPP_JWT_PREVIOUS_SECRETS`). Requires the + * `admin` scope. An `admin` token cannot make another `admin` token: those come from + * `sensapp generate-token`, which needs the secret. Not available when authentication is disabled. + */ +export const createToken = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/admin/tokens', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Database Vacuuming * - * Cleans up and optimizes the database by removing unused data and reclaiming space. - * (only if supported by the underlying storage engine). + * Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle + * of a request leave some), then cleans up and optimizes the database and reclaims space, as far as + * the storage backend supports it. Only exact duplicates go: two different values at the same + * timestamp are both kept. Requires the `delete` scope, and a token without a sensor allow list. */ -export const vacuumDatabase = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/v1/admin/vacuum', ...options }); +export const vacuumDatabase = (options?: Options): RequestResult => (options?.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/admin/vacuum', + ...options +}); /** * Prometheus Remote Read API. @@ -35,7 +60,11 @@ export const vacuumDatabase = (options?: O * * It follows the [Prometheus Remote Read specification](https://prometheus.io/docs/prometheus/latest/querying/remote_read_api/). */ -export const prometheusRemoteRead = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/prometheus_remote_read', ...options }); +export const prometheusRemoteRead = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/prometheus_remote_read', + ...options +}); /** * Prometheus Remote Write API. @@ -44,7 +73,11 @@ export const prometheusRemoteRead = (optio * * It follows the [Prometheus Remote Write specification](https://prometheus.io/docs/concepts/remote_write_spec/). */ -export const publishPrometheus = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/prometheus_remote_write', ...options }); +export const publishPrometheus = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/prometheus_remote_write', + ...options +}); /** * Simple PromQL query endpoint. @@ -59,7 +92,11 @@ export const publishPrometheus = (options: * - Range query: `GET /api/v1/query?query=my_metric[5m]` * - With format: `GET /api/v1/query?query=my_metric&format=csv` */ -export const simplePromqlQuery = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/query', ...options }); +export const simplePromqlQuery = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/query', + ...options +}); /** * InfluxDB Compatible Write API. @@ -69,6 +106,7 @@ export const simplePromqlQuery = (options: */ export const publishInfluxdb = (options: Options): RequestResult => (options.client ?? client).post({ bodySerializer: null, + security: [{ scheme: 'bearer', type: 'http' }], url: '/api/v2/write', ...options, headers: { @@ -96,9 +134,17 @@ export const readiness = (options?: Option /** * List unique metrics (measurement types) with aggregated information in DCAT catalog format. */ -export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/metrics', ...options }); +export const listMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/metrics', + ...options +}); -export const prometheusMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/prometheus/metrics', ...options }); +export const prometheusMetrics = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/prometheus/metrics', + ...options +}); /** * SensApp native data ingestion API supporting multiple formats. @@ -106,12 +152,13 @@ export const prometheusMetrics = (options? * Accepts sensor data in one of the following formats: * - **SenML JSON** (RFC 8428): `Content-Type: application/json` * - **CSV**: `Content-Type: text/csv` or `application/csv` - * - **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.file` + * - **Apache Arrow IPC**: `Content-Type: application/vnd.apache.arrow.stream` * * If no Content-Type header is provided, defaults to CSV format. */ export const publishSensorsData = (options: Options): RequestResult => (options.client ?? client).post({ bodySerializer: null, + security: [{ scheme: 'bearer', type: 'http' }], url: '/publish', ...options, headers: { @@ -123,9 +170,61 @@ export const publishSensorsData = (options /** * List all series (time series) in DCAT catalog format. */ -export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/series', ...options }); +export const listSeries = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series', + ...options +}); + +/** + * Delete a series: all its samples, its labels and the sensor itself. + * + * Publishing the same sensor again recreates it with the same UUID. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeries = (options: Options): RequestResult => (options.client ?? client).delete({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}', + ...options +}); /** * Get series data in various formats based on query parameter. */ -export const getSeriesData = (options: Options): RequestResult => (options.client ?? client).get({ url: '/series/{series_uuid}', ...options }); +export const getSeriesData = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}', + ...options +}); + +/** + * Get presence and optional bucket coverage for a series over a time window. + */ +export const getSeriesAvailability = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/availability', + ...options +}); + +/** + * Get the most recent sample for a series, optionally within a bounded time window. + */ +export const getSeriesLastSample = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/last', + ...options +}); + +/** + * Delete the samples of a series between `start` and `end`, both inclusive. + * + * Both bounds are required, so that a request cannot wipe a whole series by + * accident (use `DELETE /series/{series_uuid}` for that). `start` equal to `end` + * deletes the samples at one exact timestamp. The sensor and its labels are kept. + * Requires the `delete` scope when authentication is enabled. + */ +export const deleteSeriesSamples = (options: Options): RequestResult => (options.client ?? client).delete({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/series/{series_uuid}/samples', + ...options +}); diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0ad6570e..2b11097a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -18,6 +18,50 @@ export type AppError = { Storage: string; }; +/** + * What to put in a token: who it is for, what it may do, for how long. + */ +export type CreateTokenRequest = { + /** + * Validity of the token, in seconds, at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (a year by + * default). + */ + duration_seconds: number; + /** + * What the token may do, among `read`, `write` and `delete`. `admin` tokens are only made with + * `sensapp generate-token`. + */ + scope: Array; + /** + * Names of the sensors the token may access. Leave it out for every sensor. + */ + sensors?: Array | null; + /** + * Who or what the token is for: a service, a device, a person. It is in the logs of every + * request the token makes. + */ + subject: string; +}; + +export type CreatedToken = { + /** + * Expiration time, Unix timestamp in seconds + */ + expires_at: number; + /** + * Unique id of the token, in the logs of the requests it makes + */ + jti: string; + scope: Array; + sensors?: Array | null; + subject: string; + /** + * The token, to use as `Authorization: Bearer `. This is the only time it is shown: + * SensApp does not keep tokens. + */ + token: string; +}; + export type HealthResponse = { status: string; }; @@ -28,6 +72,21 @@ export type ReadinessResponse = { status: string; }; +/** + * What a vacuum did. + */ +export type VacuumResponse = { + /** + * Number of duplicate samples removed (same series, same timestamp, same value, the first + * one written is kept), or `null` when the storage backend cannot remove duplicates + */ + duplicates_removed?: number | null; + /** + * `ok` when the maintenance completed + */ + status: string; +}; + export type FrontpageData = { body?: never; path?: never; @@ -37,13 +96,48 @@ export type FrontpageData = { export type FrontpageResponses = { /** - * SensApp Frontpage + * SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served */ 200: string; }; export type FrontpageResponse = FrontpageResponses[keyof FrontpageResponses]; +export type CreateTokenData = { + body: CreateTokenRequest; + path?: never; + query?: never; + url: '/api/v1/admin/tokens'; +}; + +export type CreateTokenErrors = { + /** + * The request is not valid: empty subject, unknown scope, too long a duration + */ + 400: unknown; + /** + * Missing or invalid token + */ + 401: unknown; + /** + * The token does not have the admin scope, or the request asks for an admin token + */ + 403: unknown; + /** + * Authentication is disabled: there is nothing to sign a token with + */ + 404: unknown; +}; + +export type CreateTokenResponses = { + /** + * The token + */ + 200: CreatedToken; +}; + +export type CreateTokenResponse = CreateTokenResponses[keyof CreateTokenResponses]; + export type VacuumDatabaseData = { body?: never; path?: never; @@ -52,19 +146,31 @@ export type VacuumDatabaseData = { }; export type VacuumDatabaseErrors = { + /** + * The token does not have the delete scope, or has a sensor allow list + */ + 403: unknown; /** * Failed to vacuum database */ 500: string; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_MAINTENANCE_TIMEOUT_SECONDS (one hour by default), and the storage backend may carry on after the answer + */ + 504: unknown; }; export type VacuumDatabaseError = VacuumDatabaseErrors[keyof VacuumDatabaseErrors]; export type VacuumDatabaseResponses = { /** - * Database vacuum completed successfully + * Maintenance completed, with the number of duplicate samples removed */ - 200: string; + 200: VacuumResponse; }; export type VacuumDatabaseResponse = VacuumDatabaseResponses[keyof VacuumDatabaseResponses]; @@ -102,6 +208,14 @@ export type PrometheusRemoteReadErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type PrometheusRemoteReadError = PrometheusRemoteReadErrors[keyof PrometheusRemoteReadErrors]; @@ -146,6 +260,14 @@ export type PublishPrometheusErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice + */ + 504: unknown; }; export type PublishPrometheusError = PublishPrometheusErrors[keyof PublishPrometheusErrors]; @@ -171,6 +293,10 @@ export type SimplePromqlQueryData = { * Output format: senml (default), csv, jsonl, or arrow */ format?: string; + /** + * Bucket width for aggregation queries, using Prometheus duration syntax (e.g., '5m'). Without it, the whole window is one bucket + */ + step?: string; }; url: '/api/v1/query'; }; @@ -184,6 +310,14 @@ export type SimplePromqlQueryErrors = { * Internal server error */ 500: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type SimplePromqlQueryResponses = { @@ -229,6 +363,14 @@ export type PublishInfluxdbErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice + */ + 504: unknown; }; export type PublishInfluxdbError = PublishInfluxdbErrors[keyof PublishInfluxdbErrors]; @@ -303,6 +445,17 @@ export type ListMetricsData = { url: '/metrics'; }; +export type ListMetricsErrors = { + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + export type ListMetricsResponses = { /** * Metrics catalog in DCAT format @@ -313,7 +466,20 @@ export type ListMetricsResponses = { export type PrometheusMetricsData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Append the most recent sample for Prometheus-compatible series + */ + include_latest_samples?: boolean; + /** + * Optional metric name filter used when include_latest_samples=true + */ + metric?: string; + /** + * Optional PromQL-style label selector used when include_latest_samples=true + */ + selector?: string; + }; url: '/prometheus/metrics'; }; @@ -345,6 +511,14 @@ export type PublishSensorsDataErrors = { * Internal Server Error */ 500: AppError; + /** + * The storage backend is unavailable, or SensApp is busy writing and sheds the write. A `Retry-After` header (seconds, randomised) means the request was not processed at all and can be sent again after that delay + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_WRITE_TIMEOUT_SECONDS (five minutes by default), usually because the storage backend hangs. The batches of samples already written stay stored, so a retry can store them twice + */ + 504: unknown; }; export type PublishSensorsDataError = PublishSensorsDataErrors[keyof PublishSensorsDataErrors]; @@ -370,17 +544,84 @@ export type ListSeriesData = { * PromQL-style label selector (e.g., '{env="prod",region=~"us.*"}') */ selector?: string; + /** + * Series per page, 256 by default, 16384 at most + */ + limit?: number; + /** + * Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page + */ + bookmark?: string; }; url: '/series'; }; +export type ListSeriesErrors = { + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + export type ListSeriesResponses = { /** - * Time series catalog in DCAT format + * Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page */ 200: unknown; }; +export type DeleteSeriesData = { + body?: never; + path: { + /** + * UUID of the series to delete + */ + series_uuid: string; + }; + query?: never; + url: '/series/{series_uuid}'; +}; + +export type DeleteSeriesErrors = { + /** + * Invalid UUID + */ + 400: unknown; + /** + * The token does not have the delete scope + */ + 403: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend does not support deletion + */ + 501: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type DeleteSeriesResponses = { + /** + * Series deleted + */ + 204: void; +}; + +export type DeleteSeriesResponse = DeleteSeriesResponses[keyof DeleteSeriesResponses]; + export type GetSeriesDataData = { body?: never; path: { @@ -403,9 +644,29 @@ export type GetSeriesDataData = { */ end?: string; /** - * Maximum number of samples (default: 10,000,000) + * Maximum number of samples (at most 100,000). Returns the oldest N samples of the window, in time order, and silently drops the rest. With simplify, it bounds the rows read before simplification */ limit?: number; + /** + * Bucket width using Prometheus duration syntax (e.g., '1h', '5m', '1d') + */ + step?: string; + /** + * Aggregation function: avg, min, max, sum, count, first, last + */ + aggregation?: string; + /** + * Explicitly enable simplify-based point reduction. Applied to the rows read for the window (after bucketing when step is set); more than 100,000 rows to simplify is rejected with HTTP 400 instead of being truncated, so use step/aggregation or a narrower start/end + */ + simplify?: boolean; + /** + * Dimensionless simplify tolerance on normalized time/value coordinates + */ + simplify_tolerance?: number; + /** + * Use Douglas-Peucker only when simplifying + */ + simplify_high_quality?: boolean; }; url: '/series/{series_uuid}'; }; @@ -419,6 +680,14 @@ export type GetSeriesDataErrors = { * Series not found */ 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; }; export type GetSeriesDataResponses = { @@ -427,3 +696,156 @@ export type GetSeriesDataResponses = { */ 200: unknown; }; + +export type GetSeriesAvailabilityData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query: { + /** + * Inclusive start datetime in ISO 8601 format + */ + start: string; + /** + * Inclusive end datetime in ISO 8601 format + */ + end: string; + /** + * Optional bucket width using Prometheus duration syntax for coverage calculation + */ + step?: string; + }; + url: '/series/{series_uuid}/availability'; +}; + +export type GetSeriesAvailabilityErrors = { + /** + * Invalid query parameters + */ + 400: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type GetSeriesAvailabilityResponses = { + /** + * Availability information for the requested series and window + */ + 200: unknown; +}; + +export type GetSeriesLastSampleData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query?: { + /** + * Optional inclusive start datetime in ISO 8601 format + */ + start?: string; + /** + * Optional inclusive end datetime in ISO 8601 format + */ + end?: string; + }; + url: '/series/{series_uuid}/last'; +}; + +export type GetSeriesLastSampleErrors = { + /** + * Invalid query parameters + */ + 400: unknown; + /** + * Series not found or no sample matched the requested window + */ + 404: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type GetSeriesLastSampleResponses = { + /** + * Latest sample for the requested series + */ + 200: unknown; +}; + +export type DeleteSeriesSamplesData = { + body?: never; + path: { + /** + * UUID of the series + */ + series_uuid: string; + }; + query: { + /** + * Inclusive start datetime in ISO 8601 format + */ + start: string; + /** + * Inclusive end datetime in ISO 8601 format + */ + end: string; + }; + url: '/series/{series_uuid}/samples'; +}; + +export type DeleteSeriesSamplesErrors = { + /** + * Invalid UUID, or missing or invalid time bounds + */ + 400: unknown; + /** + * The token does not have the delete scope + */ + 403: unknown; + /** + * Series not found + */ + 404: unknown; + /** + * The storage backend does not support deletion + */ + 501: unknown; + /** + * The storage backend is unavailable: retry later + */ + 503: unknown; + /** + * The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs + */ + 504: unknown; +}; + +export type DeleteSeriesSamplesResponses = { + /** + * Number of deleted samples + */ + 200: unknown; +}; diff --git a/frontend/src/components/AuthDialog.tsx b/frontend/src/components/AuthDialog.tsx new file mode 100644 index 00000000..054574a4 --- /dev/null +++ b/frontend/src/components/AuthDialog.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; +import { ADMIN_TOKEN_COMMAND } from '../lib/credentials'; +import { normalizeToken, useAuthStore } from '../stores/useAuthStore'; + +/** Asks for a JWT when the server refuses a request (401, or 403 for a token that is not enough). */ +export function AuthDialog() { + const { dialogOpen, message, token, signIn, closeDialog } = useAuthStore(); + const queryClient = useQueryClient(); + const [input, setInput] = useState(''); + // The page that makes tokens needs an admin one, the others a token that reads + const command = useLocation().pathname === '/credentials' ? ADMIN_TOKEN_COMMAND : 'sensapp generate-token ui --scope read'; + + if (!dialogOpen) return null; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const candidate = normalizeToken(input); + if (!candidate) return; + signIn(candidate); + setInput(''); + // Everything that was refused is asked again with the token + void queryClient.invalidateQueries(); + } + + return ( +
event.key === 'Escape' && closeDialog()} + > +
+

+ {token ? 'This token is not accepted' : 'Authentication required'} +

+
+          {command}
+        
+

+ A local SensApp without a secret prints a token when it starts. +

+ + {message && ( +
+ {message} +
+ )} + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx new file mode 100644 index 00000000..5b4417b0 --- /dev/null +++ b/frontend/src/components/AuthStatus.tsx @@ -0,0 +1,41 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { describeToken, useAuthStore } from '../stores/useAuthStore'; + +/** Who is signed in, with the way out; or a way in once the server asked for a token. */ +export function AuthStatus() { + const { token, authRequired, signOut, openDialog } = useAuthStore(); + const queryClient = useQueryClient(); + + if (token) { + const info = describeToken(token); + const details = [ + info?.scope && `scope: ${info.scope}`, + info?.expires && `expires: ${info.expires.toLocaleString()}`, + ].filter(Boolean); + + function handleSignOut() { + signOut(); + // Nothing of a signed-out session stays in memory, and what is on screen asks again + void queryClient.resetQueries(); + } + + return ( +
+ {info?.subject ?? 'token'} + +
+ ); + } + + if (authRequired) { + return ( + + ); + } + + return null; +} diff --git a/frontend/src/components/ChartOptions.test.tsx b/frontend/src/components/ChartOptions.test.tsx new file mode 100644 index 00000000..b39c8dc3 --- /dev/null +++ b/frontend/src/components/ChartOptions.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ChartOptions } from './ChartOptions'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +describe('ChartOptions', () => { + beforeEach(() => useSelectionStore.setState({ chartStyle: 'line', logScale: false })); + + it('chooses the style', async () => { + const user = userEvent.setup(); + render(); + const style = screen.getByRole('combobox', { name: 'Style' }); + expect(style).toHaveValue('line'); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['line', 'step', 'area', 'stacked', 'bars']); + + await user.selectOptions(style, 'stacked'); + expect(useSelectionStore.getState().chartStyle).toBe('stacked'); + }); + + it('switches the log scale on and off', async () => { + const user = userEvent.setup(); + render(); + const log = screen.getByRole('button', { name: 'log' }); + expect(log).toHaveAttribute('aria-pressed', 'false'); + + await user.click(log); + expect(useSelectionStore.getState().logScale).toBe(true); + expect(log).toHaveAttribute('aria-pressed', 'true'); + await user.click(log); + expect(useSelectionStore.getState().logScale).toBe(false); + }); +}); diff --git a/frontend/src/components/ChartOptions.tsx b/frontend/src/components/ChartOptions.tsx new file mode 100644 index 00000000..ba7f408d --- /dev/null +++ b/frontend/src/components/ChartOptions.tsx @@ -0,0 +1,34 @@ +import { CHART_STYLES } from '../lib/chartOption'; +import type { ChartStyle } from '../lib/chartOption'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +/** How the series are drawn. */ +export function ChartOptions() { + const { chartStyle, setChartStyle, logScale, setLogScale } = useSelectionStore(); + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/CodeBlock.tsx b/frontend/src/components/CodeBlock.tsx new file mode 100644 index 00000000..e75fec83 --- /dev/null +++ b/frontend/src/components/CodeBlock.tsx @@ -0,0 +1,37 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { copyText } from '../lib/copyText'; +import { highlight } from '../lib/highlight'; + +/** Code on a dark ground, in both themes, with a button that copies it. */ +export function CodeBlock({ code, language, label }: { code: string; language: Parameters[1]; label: string }) { + const [copied, setCopied] = useState(null); + const html = useMemo(() => highlight(code, language), [code, language]); + + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); + + async function handleCopy() { + setCopied(await copyText(code)); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(null), 2000); + } + + return ( +
+
+        
+      
+ + + {copied === true ? 'Copied to the clipboard' : copied === false ? 'Could not copy' : ''} + +
+ ); +} diff --git a/frontend/src/components/CodeButton.test.tsx b/frontend/src/components/CodeButton.test.tsx new file mode 100644 index 00000000..396f7656 --- /dev/null +++ b/frontend/src/components/CodeButton.test.tsx @@ -0,0 +1,139 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import { fakeToken } from '../test/fakeToken'; +import { CodeButton } from './CodeButton'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +beforeEach(() => { + useAuthStore.setState({ token: null, authRequired: false }); + useSelectionStore.setState({ + selectedMetric: 'cpu', + selector: '', + selectedSeries: [{ uuid: UUID, name: 'cpu', labels: { host: 'a' }, type: 'float', slot: 0 }], + timeRange: { start: '2026-10-05T09:00:00.000Z', end: '2026-10-05T10:00:00.000Z' }, + relativeRange: null, + step: 'raw', + aggregation: 'avg', + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +async function openDialog() { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Code' })); + // The dialog is loaded when it is first opened + await screen.findByRole('dialog', { name: 'Code' }); + return user; +} + +const code = () => screen.getByRole('tabpanel').textContent ?? ''; + +describe('CodeButton', () => { + it('shows the Python code of the selection, then curl', async () => { + const user = await openDialog(); + expect(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain('from sensapp import SensAppClient'); + expect(code()).toContain(UUID); + + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain(`curl -sS`); + expect(code()).toContain(`/series/${UUID}?format=csv`); + }); + + it('colors the code', async () => { + await openDialog(); + expect(screen.getByRole('tabpanel').querySelectorAll('span.hljs-keyword').length).toBeGreaterThan(0); + expect(screen.getByRole('tabpanel').querySelector('span.hljs-string')).not.toBeNull(); + }); + + it('goes from a tab to the other with the arrow keys, and closes with Escape', async () => { + const user = await openDialog(); + screen.getByRole('tab', { name: 'Python' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveFocus(); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveAttribute('aria-selected', 'true'); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('copies the code that is shown', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('tab', { name: 'curl' })); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(await navigator.clipboard.readText()).toBe(code()); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('copies on a page where the clipboard API does not exist (plain http)', async () => { + const user = await openDialog(); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('says so when it cannot copy', async () => { + const user = await openDialog(); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + Object.defineProperty(document, 'execCommand', { value: vi.fn().mockReturnValue(false), configurable: true }); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(screen.getByRole('button', { name: 'Copy failed' })).toBeInTheDocument(); + }); + + it('reads the token from the environment once there is authentication, and never shows it', async () => { + const token = fakeToken({ sub: 'alice', scope: 'read' }); + useAuthStore.setState({ token }); + const user = await openDialog(); + expect(code()).toContain('os.environ["SENSAPP_TOKEN"]'); + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(code()).toContain('Authorization: Bearer $SENSAPP_TOKEN'); + expect(document.body.innerHTML).not.toContain(token); + }); + + it('has no token in the code of a server that asks for none', async () => { + await openDialog(); + expect(code()).not.toContain('SENSAPP_TOKEN'); + }); + + it('follows what is selected: the series of the metric, or the metrics', async () => { + useSelectionStore.setState({ selectedSeries: [] }); + const first = await openDialog(); + expect(code()).toContain('client.list_series(metric="cpu")'); + expect(screen.getByText(/No series is selected/)).toBeInTheDocument(); + await first.keyboard('{Escape}'); + + useSelectionStore.setState({ selectedMetric: null }); + const second = userEvent.setup(); + await second.click(screen.getByRole('button', { name: 'Code' })); + await screen.findByRole('dialog', { name: 'Code' }); + expect(code()).toContain('client.list_metrics()'); + }); + + it('asks for what the selector of the series matches, unless the user prefers the series that are checked', async () => { + useSelectionStore.setState({ selector: '{host="a"}' }); + const user = await openDialog(); + expect(code()).toContain(`selector='{host="a"}'`); + expect(code()).not.toContain(UUID); + const box = screen.getByRole('checkbox', { name: /Read every series matching/ }); + expect(box).toBeChecked(); + expect(screen.getByText(/instead of the 1 selected/)).toBeInTheDocument(); + + await user.click(box); + expect(code()).toContain(UUID); + expect(code()).not.toContain('selector='); + }); + + it('has no checkbox when no selector is typed', async () => { + await openDialog(); + expect(screen.queryByRole('checkbox')).toBeNull(); + }); +}); diff --git a/frontend/src/components/CodeButton.tsx b/frontend/src/components/CodeButton.tsx new file mode 100644 index 00000000..19174d8a --- /dev/null +++ b/frontend/src/components/CodeButton.tsx @@ -0,0 +1,29 @@ +import { lazy, Suspense, useState } from 'react'; + +// highlight.js and the snippets are loaded when the dialog is first opened +const CodeDialog = lazy(() => import('./CodeDialog')); + +/** Opens the code that loads what the explorer shows. */ +export function CodeButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( + + setOpen(false)} /> + + )} + + ); +} diff --git a/frontend/src/components/CodeDialog.tsx b/frontend/src/components/CodeDialog.tsx new file mode 100644 index 00000000..636ad6ab --- /dev/null +++ b/frontend/src/components/CodeDialog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import { resolveStep } from '../lib/chartStep'; +import { copyText } from '../lib/copyText'; +import { highlight } from '../lib/highlight'; +import { SNIPPET_LANGUAGES, snippetFor } from '../lib/snippets'; +import type { SnippetLanguage } from '../lib/snippets'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +const LABELS: Record = { python: 'Python', curl: 'curl' }; +const HIGHLIGHT_AS = { python: 'python', curl: 'bash' } as const; + +/** Code that loads what the explorer shows: the Python SDK, or curl. */ +export default function CodeDialog({ onClose }: { onClose: () => void }) { + const [language, setLanguage] = useState('python'); + const [copied, setCopied] = useState(null); + const { selectedMetric, selectedSeries, selector, timeRange, relativeRange, step, aggregation } = useSelectionStore(); + // A selector that is typed in the series list is what the code asks for, unless the user prefers the series that are checked + const [bySelector, setBySelector] = useState(true); + const typedSelector = selector.trim(); + const useSelector = typedSelector !== '' && bySelector; + const { token, authRequired } = useAuthStore(); + + const code = useMemo( + () => + snippetFor(language, { + baseUrl: import.meta.env.VITE_SENSAPP_API_URL || window.location.origin, + authenticated: token !== null || authRequired, + metric: selectedMetric, + selector: useSelector ? typedSelector : undefined, + series: selectedSeries, + timeRange, + relativeRange, + step: resolveStep(step, timeRange.start, timeRange.end), + aggregation, + }), + [language, selectedMetric, selectedSeries, useSelector, typedSelector, timeRange, relativeRange, step, aggregation, token, authRequired], + ); + const html = useMemo(() => highlight(code, HIGHLIGHT_AS[language]), [code, language]); + + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); + + async function handleCopy() { + setCopied(await copyText(code)); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(null), 2000); + } + + // Left and right go from a tab to the other, as in any tab list + function handleTabKey(event: KeyboardEvent) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const next = SNIPPET_LANGUAGES[(SNIPPET_LANGUAGES.indexOf(language) + 1) % SNIPPET_LANGUAGES.length]; + setLanguage(next); + document.getElementById(`code-tab-${next}`)?.focus(); + } + + const nothingSelected = selectedSeries.length === 0; + + return ( +
event.key === 'Escape' && onClose()} + > +
+
+

+ Code +

+ +
+ +
+
+ {SNIPPET_LANGUAGES.map((id) => ( + + ))} +
+ + + {copied === true ? 'Copied to the clipboard' : copied === false ? 'Could not copy' : ''} + +
+ +
+ {typedSelector !== '' && ( + + )} +
+            
+          
+ {nothingSelected && !useSelector && ( +

+ {selectedMetric + ? 'No series is selected: this lists the series of the metric. Select some to get their data.' + : 'Nothing is selected: this lists the metrics. Select series to get their data.'} +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/EChart.tsx b/frontend/src/components/EChart.tsx new file mode 100644 index 00000000..25018d53 --- /dev/null +++ b/frontend/src/components/EChart.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts/core'; +import { BarChart, LineChart } from 'echarts/charts'; +import { + BrushComponent, + GridComponent, + ToolboxComponent, + TooltipComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import { usePrefersDark } from '../lib/usePrefersDark'; + +// Only what the time series chart needs: the full echarts bundle is three times bigger. +echarts.use([ + BarChart, + LineChart, + BrushComponent, + GridComponent, + ToolboxComponent, + TooltipComponent, + CanvasRenderer, +]); + +/** + * Minimal echarts wrapper: one instance per mount, resized with its container. A new option is merged + * into what is drawn, so that a series that is added or removed comes and goes with an animation + * while the others stay. A drag on the chart is a window: `onBrush` gets its two ends, in ms. + */ +export default function EChart({ + option, + onBrush, + highlight, +}: { + option: echarts.EChartsCoreOption; + onBrush?: (fromMs: number, toMs: number) => void; + /** The id of a series to stand out, the others fading */ + highlight?: string | null; +}) { + const container = useRef(null); + const chart = useRef(null); + const dark = usePrefersDark(); + + const onBrushRef = useRef(onBrush); + useEffect(() => { + onBrushRef.current = onBrush; + }); + + // A new instance when the theme changes: echarts cannot switch it on a live one + useEffect(() => { + const element = container.current; + if (!element) return; + const instance = echarts.init(element, dark ? 'dark' : undefined); + chart.current = instance; + // A drag draws a window, which is asked for and then wiped: the chart shows the new one + instance.on('brushEnd', (event) => { + const range = (event as { areas?: Array<{ coordRange?: number[] }> }).areas?.[0]?.coordRange; + if (range && range.length === 2) onBrushRef.current?.(range[0], range[1]); + instance.dispatchAction({ type: 'brush', areas: [] }); + }); + const observer = new ResizeObserver(() => instance.resize()); + observer.observe(element); + return () => { + observer.disconnect(); + instance.dispose(); + chart.current = null; + }; + }, [dark]); + + useEffect(() => { + const instance = chart.current; + if (!instance) return; + // The theme brings a background of its own: the card has one already + instance.setOption( + { backgroundColor: 'transparent', ...option }, + // What a merge would keep of a series or an axis that is gone + { replaceMerge: ['series', 'yAxis'] }, + ); + // Dragging selects a window, with no button to press first + instance.dispatchAction({ + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { brushType: 'lineX', brushMode: 'single' }, + }); + }, [option, dark]); + + useEffect(() => { + const instance = chart.current; + if (!instance) return; + instance.dispatchAction({ type: 'downplay' }); + if (highlight) instance.dispatchAction({ type: 'highlight', seriesId: highlight }); + }, [highlight, option, dark]); + + return
; +} diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx new file mode 100644 index 00000000..5f53a4db --- /dev/null +++ b/frontend/src/components/Feedback.tsx @@ -0,0 +1,21 @@ +/** The spinner of a table that loads. */ +export function Loading() { + return ( +
+ + Loading... +
+ ); +} + +/** What failed, in the place of the data. */ +export function ErrorAlert({ what, error }: { what: string; error: unknown }) { + return ( +
+ + Failed to load {what}: {error instanceof Error ? error.message : 'Unknown error'} +
+ ); +} diff --git a/frontend/src/components/HealthBadge.test.tsx b/frontend/src/components/HealthBadge.test.tsx deleted file mode 100644 index e2e7a9b3..00000000 --- a/frontend/src/components/HealthBadge.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import { HealthBadge } from '../components/HealthBadge'; - -const mockReadiness = vi.fn(); -vi.mock('../client', () => ({ - readiness: (...args: unknown[]) => mockReadiness(...args), -})); - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function Wrapper({ children }: { children: ReactNode }) { - return ( - {children} - ); - }; -} - -describe('HealthBadge', () => { - it('shows "Connected" when backend is healthy', async () => { - mockReadiness.mockResolvedValue({ - data: { status: 'ready', database: 'sqlite' }, - }); - - render(, { wrapper: createWrapper() }); - expect(await screen.findByText('Connected')).toBeInTheDocument(); - }); - - it('shows "Disconnected" when backend returns error', async () => { - mockReadiness.mockResolvedValue({ - data: undefined, - error: { status: 'error' }, - }); - - render(, { wrapper: createWrapper() }); - await waitFor(() => { - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - }); - - it('shows "Disconnected" on network failure', async () => { - mockReadiness.mockRejectedValue(new Error('Network error')); - - render(, { wrapper: createWrapper() }); - await waitFor(() => { - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/components/HealthBadge.tsx b/frontend/src/components/HealthBadge.tsx deleted file mode 100644 index e13fdb8d..00000000 --- a/frontend/src/components/HealthBadge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { readiness } from '../client'; -import type { ReadinessResponse } from '../client'; - -export function HealthBadge() { - const { data, isError } = useQuery({ - queryKey: ['health'], - queryFn: async () => { - const result = await readiness(); - if (result.error) { - throw new Error('Backend unreachable'); - } - return result.data as ReadinessResponse; - }, - refetchInterval: 30000, - retry: false, - }); - - const isHealthy = data?.status === 'ready' && !isError; - - return ( -
- - - {isHealthy ? 'Connected' : 'Disconnected'} - -
- ); -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c808846f..c2e5e960 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,25 +1,63 @@ -import { Outlet } from 'react-router-dom'; -import { HealthBadge } from './HealthBadge'; +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; +import { AuthDialog } from './AuthDialog'; +import { AuthStatus } from './AuthStatus'; +import { CodeButton } from './CodeButton'; + +// The files of public/, served next to the page (`BASE_URL` is `/ui/` when SensApp serves them) +const base = import.meta.env.BASE_URL; +const logo = `${base}sensapp_logo-fs8.png`; +const logoWhite = `${base}sensapp_logo_white-fs8.png`; +const logoSmall = `${base}sensapp_logo_small-fs8.png`; +const logoSmallWhite = `${base}sensapp_logo_small_white-fs8.png`; + +const TABS = [ + { to: '/', label: 'Data Explorer', end: true }, + { to: '/load', label: 'Load Data', end: false }, + { to: '/credentials', label: 'Credentials', end: false }, +]; export function Layout() { + // The Code button is about what the explorer shows + const explorer = useLocation().pathname === '/'; return ( -
+
-
-
-
- - - - - SensApp - -
- Sensor Data Explorer -
+
+ {/* The row covers the border of the header, so the line under the current tab sits on it */} +
+
+ + {/* The white logo on a dark page, the small one (no name) on a narrow screen */} + + + + + SensApp + + +
-
- +
+ {explorer && } - API Docs + API Docs +
-
+
+
); } diff --git a/frontend/src/components/MetricsTable.test.tsx b/frontend/src/components/MetricsTable.test.tsx index d16c8216..ba528fa9 100644 --- a/frontend/src/components/MetricsTable.test.tsx +++ b/frontend/src/components/MetricsTable.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -97,6 +97,72 @@ describe('MetricsTable', () => { expect(screen.getByText('region')).toBeInTheDocument(); }); + it('shows a name that exists with two types as two rows, the full name on hover', async () => { + const [cpu] = sampleMetrics['dcat:dataset']; + mockListMetrics.mockResolvedValue({ + data: { + ...sampleMetrics, + 'dcat:dataset': [cpu, { ...cpu, 'sensor:type': 'string' }], + }, + }); + render(, { wrapper: createWrapper() }); + + const names = await screen.findAllByText('cpu_usage'); + expect(names).toHaveLength(2); + expect(names[0].closest('[title]')).toHaveAttribute('title', 'cpu_usage'); + const table = screen.getByRole('table'); + expect(within(table).getByText('float')).toBeInTheDocument(); + expect(within(table).getByText('string')).toBeInTheDocument(); + }); + + it('colors the type badge of a type that the server capitalizes', async () => { + const [cpu] = sampleMetrics['dcat:dataset']; + mockListMetrics.mockResolvedValue({ + data: { ...sampleMetrics, 'dcat:dataset': [{ ...cpu, 'sensor:type': 'Float' }] }, + }); + render(, { wrapper: createWrapper() }); + + const badge = await within(await screen.findByRole('table')).findByText('float'); + expect(badge).toHaveClass('badge-primary'); + }); + + it('has a radio button per metric, on for the metric that is open, off again when it is clicked', async () => { + const user = userEvent.setup(); + render(, { wrapper: createWrapper() }); + + const cpu = await screen.findByRole('radio', { name: 'Select metric cpu_usage' }); + expect(cpu).not.toBeChecked(); + await user.click(cpu); + expect(useSelectionStore.getState().selectedMetric).toBe('cpu_usage'); + expect(cpu).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Select metric memory_total' })).not.toBeChecked(); + + // Once, not twice: the row does not take the click of the radio again + await user.click(cpu); + expect(useSelectionStore.getState().selectedMetric).toBeNull(); + }); + + it('puts the metric of the address in the middle of the list, and does not move it for a click', async () => { + const user = userEvent.setup(); + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const top = this.dataset.selected === 'true' ? 300 : 0; + const height = this.dataset.selected === 'true' ? 20 : 100; + return { top, height, bottom: top + height, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }; + }); + useSelectionStore.setState({ selectedMetric: 'memory_total' }); + const { container } = render(, { wrapper: createWrapper() }); + + await screen.findByText('memory_total'); + const list = container.querySelector('.overflow-y-auto')!; + // 300 - (100 - 20) / 2 + await waitFor(() => expect(list.scrollTop).toBe(260)); + + list.scrollTop = 0; + await user.click(screen.getByText('cpu_usage')); + expect(list.scrollTop).toBe(0); + rect.mockRestore(); + }); + it('selects a metric when clicking a row and updates store', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); diff --git a/frontend/src/components/MetricsTable.tsx b/frontend/src/components/MetricsTable.tsx index a2a53b25..6ab63d5b 100644 --- a/frontend/src/components/MetricsTable.tsx +++ b/frontend/src/components/MetricsTable.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMetrics } from '../hooks/useMetrics'; import type { DcatDataset } from '../hooks/useMetrics'; import { useSelectionStore } from '../stores/useSelectionStore'; +import { ErrorAlert, Loading } from './Feedback'; +import { Panel } from './Panel'; const SENSOR_TYPES = [ '', @@ -27,7 +29,22 @@ export function MetricsTable() { const metrics = data?.['dcat:dataset'] ?? []; + // The page opened on a metric (the address says which): the list starts there, once. A click is on + // a row that is on screen, the list does not move for it. + const list = useRef(null); + const placed = useRef(false); + useEffect(() => { + if (placed.current || !selectedMetric || !list.current) return; + const row = list.current.querySelector('[data-selected="true"]'); + if (!row) return; + placed.current = true; + const box = list.current.getBoundingClientRect(); + const rect = row.getBoundingClientRect(); + list.current.scrollTop += rect.top - box.top - (box.height - rect.height) / 2; + }, [data, selectedMetric]); + function handleSelectMetric(metric: DcatDataset) { + placed.current = true; const metricName = metric['dct:title']; if (selectedMetric === metricName) { setSelectedMetric(null); @@ -36,8 +53,9 @@ export function MetricsTable() { } } + // The server says `Float`, the filter above `float` function sensorTypeBadgeClass(type: string): string { - switch (type) { + switch (type.toLowerCase()) { case 'float': case 'numeric': return 'badge-primary'; @@ -54,50 +72,42 @@ export function MetricsTable() { } } - return ( -
-
- setNameFilter(e.target.value)} - /> - - {!isLoading && !error && ( - - {metrics.length} metric{metrics.length !== 1 ? 's' : ''} - - )} -
- -
- {isLoading && ( -
- - Loading... -
+ const controls = ( + <> + setNameFilter(e.target.value)} + /> + + {!isLoading && !error && ( + + {metrics.length} metric{metrics.length !== 1 ? 's' : ''} + )} + + ); - {error && ( -
- - - - Failed to load metrics: {error instanceof Error ? error.message : 'Unknown error'} -
- )} + return ( + +
+ {isLoading && } + + {error && } {!isLoading && !error && metrics.length === 0 && (
@@ -118,6 +128,9 @@ export function MetricsTable() { + @@ -132,32 +145,50 @@ export function MetricsTable() { const dimensions = metric['sensor:labelDimensions'] ?? []; return ( handleSelectMetric(metric)} > + {/* Takes what the other columns leave, and cuts the name there (`max-w-0` is what lets it) */} +
+ Select + Metric Name Type Series
- {name} - {metric['sensor:unit'] && ( - ({metric['sensor:unit']}) - )} + {/* The metric that is open, as the checkbox of a series is a series that is drawn */} + {}} + onClick={(e) => { + e.stopPropagation(); + handleSelectMetric(metric); + }} + /> + +
+ {name} + {metric['sensor:unit'] && ( + ({metric['sensor:unit']}) + )} +
- - {metric['sensor:type']} + + {metric['sensor:type'].toLowerCase()} {seriesCount ?? '—'} -
+
{dimensions.slice(0, 5).map((dim) => ( - + {dim} ))} @@ -174,6 +205,6 @@ export function MetricsTable() {
)}
- + ); } diff --git a/frontend/src/components/Panel.tsx b/frontend/src/components/Panel.tsx new file mode 100644 index 00000000..5c8b6bf0 --- /dev/null +++ b/frontend/src/components/Panel.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +/** A card of the explorer: a title with its controls on one row, then the content. */ +export function Panel({ title, controls, children }: { title: ReactNode; controls?: ReactNode; children: ReactNode }) { + return ( +
+
+

{title}

+ {controls} +
+
{children}
+
+ ); +} diff --git a/frontend/src/components/SeriesTable.test.tsx b/frontend/src/components/SeriesTable.test.tsx new file mode 100644 index 00000000..c8ee3e3a --- /dev/null +++ b/frontend/src/components/SeriesTable.test.tsx @@ -0,0 +1,408 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { SeriesTable } from './SeriesTable'; +import { SERIES_COLORS } from '../lib/palette'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +const mockListSeries = vi.fn(); +vi.mock('../client', () => ({ + listSeries: (...args: unknown[]) => mockListSeries(...args), +})); + +const dataset = (uuid: string, host: string) => ({ + '@type': 'dcat:Dataset', + '@id': `temperature{host="${host}"}`, + 'dct:identifier': uuid, + 'dct:title': 'temperature', + 'sensor:type': 'float', + 'sensor:labels': [{ host }], + 'dcat:distribution': [], +}); + +const catalog = { + '@type': 'dcat:Catalog', + 'dcat:dataset': [dataset('uuid-a', 'alpha'), dataset('uuid-b', 'beta')], +}; + +function renderTable() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe('SeriesTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListSeries.mockResolvedValue({ data: catalog }); + useSelectionStore.setState({ selectedMetric: 'temperature', selectedSeries: [], labelFilter: '' }); + }); + + it('lists the series of the metric with a column for each label, and the uuid in small', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'dcat:dataset': [ + { ...dataset('uuid-a', 'alpha'), 'sensor:labels': [{ host: 'alpha', room: 'lab' }] }, + dataset('uuid-b', 'beta'), + ], + }, + }); + renderTable(); + + await screen.findByText('uuid-a'); + expect(screen.getAllByTitle(/^Sort by/).map((b) => b.title)).toEqual(['Sort by host', 'Sort by room']); + const [, alpha, beta] = screen.getAllByRole('row'); + expect(within(alpha).getAllByRole('cell').map((c) => c.textContent)).toEqual(['', 'alpha', 'lab', 'uuid-a']); + // A label that a series does not have is a dash + expect(within(beta).getAllByRole('cell').map((c) => c.textContent)).toEqual(['', 'beta', '—', 'uuid-b']); + // The type is the one of the metric: no column for it + expect(screen.queryByText('float')).not.toBeInTheDocument(); + }); + + it('says the type of each series when the metric has more than one', async () => { + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'dcat:dataset': [dataset('uuid-a', 'alpha'), { ...dataset('uuid-b', 'beta'), 'sensor:type': 'string' }] }, + }); + renderTable(); + + await screen.findByText('uuid-a'); + expect(screen.getByRole('columnheader', { name: 'Type' })).toBeInTheDocument(); + expect(screen.getByText('float')).toBeInTheDocument(); + expect(screen.getByText('string')).toBeInTheDocument(); + }); + + it('counts the series in the row of the filters, and says when it is the page or the filter', async () => { + const user = userEvent.setup(); + renderTable(); + expect(await screen.findByText('2 series')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('Quick filter...'), 'beta'); + expect(screen.getByText('1 of 2 series')).toBeInTheDocument(); + }); + + it('has the pager in that row too, and counts the page', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'hydra:view': { '@type': 'hydra:PartialCollectionView', 'hydra:next': '/series?bookmark=p2', 'hydra:itemsPerPage': 2 }, + }, + }); + renderTable(); + expect(await screen.findByText('2 series on this page')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); + }); + + describe('sorting', () => { + const hosts = () => + screen + .getAllByRole('row') + .slice(1) + .map((row) => within(row).getAllByRole('cell')[1].textContent); + const withHosts = (...names: string[]) => + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'dcat:dataset': names.map((name, i) => dataset(`uuid-${i}`, name)) }, + }); + + it('sorts by the first column, numbers in natural order', async () => { + withHosts('node-10', 'node-2', 'node-1'); + renderTable(); + await screen.findByText('uuid-0'); + expect(hosts()).toEqual(['node-1', 'node-2', 'node-10']); + expect(screen.getByRole('columnheader', { name: /host/ })).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('sorts by the column that is clicked, and the other way when it is clicked again', async () => { + const user = userEvent.setup(); + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'dcat:dataset': [ + { ...dataset('uuid-a', 'alpha'), 'sensor:labels': [{ host: 'alpha', room: 'z' }] }, + { ...dataset('uuid-b', 'beta'), 'sensor:labels': [{ host: 'beta', room: 'a' }] }, + ], + }, + }); + renderTable(); + await screen.findByText('uuid-a'); + expect(hosts()).toEqual(['alpha', 'beta']); + + await user.click(screen.getByTitle('Sort by room')); + expect(hosts()).toEqual(['beta', 'alpha']); + await user.click(screen.getByTitle('Sort by room')); + expect(hosts()).toEqual(['alpha', 'beta']); + expect(screen.getByRole('columnheader', { name: /room/ })).toHaveAttribute('aria-sort', 'descending'); + await user.click(screen.getByTitle('Sort by host')); + expect(screen.getByRole('columnheader', { name: /host/ })).toHaveAttribute('aria-sort', 'ascending'); + }); + }); + + describe('labels that are on every series', () => { + const influx = (host: string) => ({ + ...dataset(`uuid-${host}`, host), + 'sensor:labels': [{ influxdb_org: 'sensapp', host }], + }); + + it('are said once above the list, not in a column', async () => { + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': [influx('alpha'), influx('beta')] } }); + renderTable(); + + await screen.findByText('uuid-alpha'); + expect(screen.getByText('on every series')).toBeInTheDocument(); + expect(screen.getByText('influxdb_org=')).toBeInTheDocument(); + expect(screen.queryByTitle('Sort by influxdb_org')).not.toBeInTheDocument(); + expect(screen.getByTitle('Sort by host')).toBeInTheDocument(); + }); + + it('stay a column when there is one series: it would have nothing else to show', async () => { + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': [influx('alpha')] } }); + renderTable(); + + await screen.findByText('uuid-alpha'); + expect(screen.queryByText('on every series')).not.toBeInTheDocument(); + expect(screen.getByTitle('Sort by influxdb_org')).toBeInTheDocument(); + }); + }); + + it('suggests a selector made of labels of the series', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'dcat:dataset': [{ ...dataset('uuid-a', 'alpha'), 'sensor:labels': [{ host: 'alpha', room: 'lab' }] }], + }, + }); + renderTable(); + await screen.findByText('uuid-a'); + expect(screen.getByPlaceholderText('{host="alpha", room=~"l.*"}')).toBeInTheDocument(); + }); + + it('tells the chart which series the pointer is on, and forgets it when it leaves', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-a'); + const row = screen.getByText('uuid-b').closest('tr')!; + + await user.hover(row); + expect(useSelectionStore.getState().hoveredSeries).toBe('uuid-b'); + await user.unhover(row); + expect(useSelectionStore.getState().hoveredSeries).toBeNull(); + }); + + describe('selection', () => { + const uuids = () => useSelectionStore.getState().selectedSeries.map((s) => s.uuid); + + it('selects, and unselects, what is on screen from the header', async () => { + const user = userEvent.setup(); + const many = Array.from({ length: 10 }, (_, i) => dataset(`uuid-${i}`, `host-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + await screen.findByText('uuid-0'); + const all = screen.getByRole('checkbox', { name: 'Select all series' }); + expect(all).not.toBeChecked(); + + await user.click(all); + expect(uuids()).toHaveLength(10); + // Ten series, ten colors: none repeated + expect(new Set(useSelectionStore.getState().selectedSeries.map((s) => s.slot)).size).toBe(10); + expect(all).toBeChecked(); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-3' })); + expect(all).not.toBeChecked(); + expect((all as HTMLInputElement).indeterminate).toBe(true); + + await user.click(all); + expect(uuids()).toHaveLength(10); + await user.click(all); + expect(uuids()).toEqual([]); + }); + + it('selects only what the quick filter lets through', async () => { + const user = userEvent.setup(); + const many = Array.from({ length: 10 }, (_, i) => dataset(`uuid-${i}`, i < 3 ? `lab-${i}` : `other-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + await screen.findByText('uuid-0'); + + await user.type(screen.getByPlaceholderText('Quick filter...'), 'lab'); + await user.click(screen.getByRole('checkbox', { name: 'Select all series' })); + expect(uuids().sort()).toEqual(['uuid-0', 'uuid-1', 'uuid-2']); + }); + + it('selects all the series of a metric that is not too big, with a color each', async () => { + renderTable(); + + await waitFor(() => expect(uuids()).toEqual(['uuid-a', 'uuid-b'])); + expect(useSelectionStore.getState().selectedSeries[0]).toEqual({ + uuid: 'uuid-a', + name: 'temperature', + labels: { host: 'alpha' }, + type: 'float', + slot: 0, + }); + // The checkbox of a series has the color of its line + const first = screen.getByRole('checkbox', { name: 'Select series uuid-a' }); + const second = screen.getByRole('checkbox', { name: 'Select series uuid-b' }); + expect(first).toBeChecked(); + expect(first.style.getPropertyValue('--input-color')).toBe(SERIES_COLORS.light[0]); + expect(second.style.getPropertyValue('--input-color')).toBe(SERIES_COLORS.light[1]); + expect(screen.getByRole('button', { name: 'Clear (2)' })).toBeInTheDocument(); + }); + + it('does not decide for the user when there are too many series', async () => { + const many = Array.from({ length: 9 }, (_, i) => dataset(`uuid-${i}`, `host-${i}`)); + mockListSeries.mockResolvedValue({ data: { ...catalog, 'dcat:dataset': many } }); + renderTable(); + + await screen.findByText('uuid-0'); + expect(uuids()).toEqual([]); + }); + + it('does not decide either when there is another page', async () => { + mockListSeries.mockResolvedValue({ + data: { + ...catalog, + 'hydra:view': { '@type': 'hydra:PartialCollectionView', 'hydra:next': '/series?bookmark=p2', 'hydra:itemsPerPage': 2 }, + }, + }); + renderTable(); + + await screen.findByText('uuid-a'); + expect(uuids()).toEqual([]); + }); + + it('leaves what the address selected', async () => { + useSelectionStore.getState().selectSeries([{ uuid: 'uuid-b', name: 'temperature', labels: { host: 'beta' }, type: 'float' }]); + renderTable(); + + await screen.findByText('uuid-a'); + expect(uuids()).toEqual(['uuid-b']); + }); + + it('does not select what cannot be drawn', async () => { + mockListSeries.mockResolvedValue({ + data: { ...catalog, 'dcat:dataset': [dataset('uuid-a', 'alpha'), { ...dataset('uuid-b', 'beta'), 'sensor:type': 'string' }] }, + }); + renderTable(); + + await waitFor(() => expect(uuids()).toEqual(['uuid-a'])); + }); + + it('does it once: what the user clears stays cleared', async () => { + const user = userEvent.setup(); + renderTable(); + await waitFor(() => expect(uuids()).toHaveLength(2)); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-a' })); + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-b' })); + expect(uuids()).toEqual([]); + }); + + it('keeps the color of a series when another one is unselected', async () => { + const user = userEvent.setup(); + renderTable(); + await waitFor(() => expect(uuids()).toHaveLength(2)); + + await user.click(screen.getByRole('checkbox', { name: 'Select series uuid-a' })); + expect(useSelectionStore.getState().selectedSeries).toMatchObject([{ uuid: 'uuid-b', slot: 1 }]); + }); + }); + + it('filters the list as people type', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-a'); + + await user.type(screen.getByPlaceholderText('Quick filter...'), 'beta'); + expect(screen.queryByText('uuid-a')).not.toBeInTheDocument(); + expect(screen.getByText('uuid-b')).toBeInTheDocument(); + }); + + describe('pages', () => { + const page = (uuid: string, host: string, bookmark?: string) => ({ + '@type': 'dcat:Catalog', + 'dcat:dataset': [dataset(uuid, host)], + ...(bookmark && { + 'hydra:view': { + '@type': 'hydra:PartialCollectionView', + 'hydra:next': `/series?limit=1&bookmark=${bookmark}&metric=temperature`, + 'hydra:itemsPerPage': 1, + }, + }), + }); + + beforeEach(() => { + mockListSeries.mockImplementation(({ query }) => + Promise.resolve({ + data: + query.bookmark === 'p2' + ? page('uuid-2', 'beta', 'p3') + : query.bookmark === 'p3' + ? page('uuid-3', 'gamma') + : page('uuid-1', 'alpha', 'p2'), + }), + ); + }); + + it('goes forward with the bookmark of the server, and back', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-1'); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + expect(await screen.findByText('uuid-2')).toBeInTheDocument(); + expect(mockListSeries).toHaveBeenLastCalledWith({ + query: { metric: 'temperature', selector: undefined, bookmark: 'p2' }, + }); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + expect(await screen.findByText('uuid-3')).toBeInTheDocument(); + // The last page has no next + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Previous page' })); + expect(await screen.findByText('uuid-2')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Previous page' })); + expect(await screen.findByText('uuid-1')).toBeInTheDocument(); + }); + + it('keeps the selection from one page to the other', async () => { + const user = userEvent.setup(); + renderTable(); + await user.click(await screen.findByRole('checkbox', { name: 'Select series uuid-1' })); + await user.click(screen.getByRole('button', { name: 'Next page' })); + await user.click(await screen.findByRole('checkbox', { name: 'Select series uuid-2' })); + expect(useSelectionStore.getState().selectedSeries.map((s) => s.uuid)).toEqual(['uuid-1', 'uuid-2']); + }); + + it('starts again from the first page when the selector changes', async () => { + const user = userEvent.setup(); + renderTable(); + await screen.findByText('uuid-1'); + await user.click(screen.getByRole('button', { name: 'Next page' })); + await screen.findByText('uuid-2'); + + await user.type(screen.getByPlaceholderText(/host=/), 'x'); + expect(await screen.findByText('uuid-1')).toBeInTheDocument(); + expect(mockListSeries.mock.lastCall?.[0].query.bookmark).toBeUndefined(); + }); + + it('has no pager when everything fits on one page', async () => { + mockListSeries.mockResolvedValue({ data: catalog }); + renderTable(); + await screen.findByText('uuid-a'); + expect(screen.queryByRole('button', { name: 'Next page' })).not.toBeInTheDocument(); + }); + }); + + it('shows what failed', async () => { + mockListSeries.mockResolvedValue({ error: { error: 'Selector is invalid' }, response: { status: 400 } }); + renderTable(); + expect(await screen.findByText(/Failed to load series: Selector is invalid/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/SeriesTable.tsx b/frontend/src/components/SeriesTable.tsx index af311959..384b0eb8 100644 --- a/frontend/src/components/SeriesTable.tsx +++ b/frontend/src/components/SeriesTable.tsx @@ -1,7 +1,15 @@ -import { useState } from 'react'; -import { useSeries } from '../hooks/useSeries'; +import { useEffect, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { nextBookmark, useSeries } from '../hooks/useSeries'; import type { SeriesDataset } from '../hooks/useSeries'; +import { isBooleanType, isNumericType } from '../lib/chartStep'; +import { DISTINCT_COLORS, readableOn, seriesColor } from '../lib/palette'; +import { exampleSelector, sharedLabels, sortByColumns } from '../lib/seriesLabels'; +import { usePrefersDark } from '../lib/usePrefersDark'; import { useSelectionStore } from '../stores/useSelectionStore'; +import type { SeriesInfo } from '../stores/useSelectionStore'; +import { ErrorAlert, Loading } from './Feedback'; +import { Panel } from './Panel'; function labelsToRecord( labels?: Array> @@ -13,39 +21,80 @@ function labelsToRecord( ); } +/** A metric of at most this many series is shown whole when it is selected: they have colors that are clearly apart. */ +const AUTO_SELECT_MAX = DISTINCT_COLORS; + +function toSeriesInfo(dataset: SeriesDataset): SeriesInfo { + return { + uuid: dataset['dct:identifier'], + name: dataset['dct:title'], + labels: labelsToRecord(dataset['sensor:labels']), + type: dataset['sensor:type'], + }; +} + export function SeriesTable() { const { selectedMetric, selectedSeries, toggleSeries, + selectSeries, + unselectSeries, + clearSelectedSeries, + setHoveredSeries, labelFilter, setLabelFilter, + selector: selectorInput, + setSelector, } = useSelectionStore(); - const [selectorInput, setSelectorInput] = useState(''); + const dark = usePrefersDark(); + // The cursor of every page shown so far: the server only goes forward, so Previous pops one + const [bookmarks, setBookmarks] = useState([]); + // Sorted by the first column, until another one is clicked, and again to reverse it + const [sort, setSort] = useState<{ column?: string; descending: boolean }>({ descending: false }); const selector = selectorInput || undefined; const { data, isLoading, error } = useSeries({ metric: selectedMetric || undefined, selector, + bookmark: bookmarks.at(-1), }); + const next = nextBookmark(data); const series = data?.['dcat:dataset'] ?? []; - function isSelected(dataset: SeriesDataset): boolean { - return selectedSeries.some( - (s) => s.uuid === dataset['dct:identifier'] + // A metric that is not too big is shown whole, once: clearing it afterwards is the choice of the + // user. What the address already shows is left alone, and so are the series that cannot be drawn. + const autoSelected = useRef(false); + useEffect(() => { + if (!data || autoSelected.current) return; + autoSelected.current = true; + const all = data['dcat:dataset']; + if (nextBookmark(data) || all.length > AUTO_SELECT_MAX) return; + if (useSelectionStore.getState().selectedSeries.length > 0) return; + selectSeries( + all + .map(toSeriesInfo) + .filter((s) => isNumericType(s.type) || isBooleanType(s.type)), ); + }, [data, selectSeries]); + + function selectedOf(dataset: SeriesDataset) { + return selectedSeries.find((s) => s.uuid === dataset['dct:identifier']); } - function handleToggle(dataset: SeriesDataset) { - toggleSeries({ - uuid: dataset['dct:identifier'], - name: dataset['dct:title'], - labels: labelsToRecord(dataset['sensor:labels']), - type: dataset['sensor:type'], - }); + // One column per label dimension, but what is the same on every series is said once, above + const labelsList = series.map((s) => labelsToRecord(s['sensor:labels'])); + const dimensions = [...new Set(labelsList.flatMap(Object.keys))].sort(); + const shared = sharedLabels(labelsList); + const columns = dimensions.filter((dimension) => !(dimension in shared)); + const sortedBy = sort.column ?? columns[0]; + function sortBy(column: string) { + setSort(column === sortedBy ? { column, descending: !sort.descending } : { column, descending: false }); } + // The type is said when there is something to tell: a name can exist with more than one + const mixedTypes = new Set(series.map((s) => s['sensor:type'])).size > 1; // Client-side label text filtering const filteredSeries = labelFilter @@ -58,51 +107,103 @@ export function SeriesTable() { return searchStr.includes(labelFilter.toLowerCase()); }) : series; + // The server pages by creation, so this is the order of the page on screen + const sortedSeries = sortByColumns( + filteredSeries, + (s) => labelsToRecord(s['sensor:labels']), + columns, + sortedBy, + sort.descending, + ); + + const visibleSelected = sortedSeries.filter((s) => selectedOf(s)).length; if (!selectedMetric) { - return null; + return ( + +
+

Select a metric to browse series

+
+
+ ); } - return ( -
-
- setSelectorInput(e.target.value)} - /> - setLabelFilter(e.target.value)} - /> - {selectedSeries.length > 0 && ( - - {selectedSeries.length} selected - - )} -
- -
- {isLoading && ( -
- - Loading... -
+ const paged = bookmarks.length > 0 || !!next; + const controls = ( + <> + 0 ? columns : dimensions)} + aria-label="Selector" + className="input input-xs font-mono text-xs flex-1 min-w-32 max-w-56 h-7" + value={selectorInput} + onChange={(e) => { + setSelector(e.target.value); + setBookmarks([]); + }} + /> + setLabelFilter(e.target.value)} + /> + {!isLoading && !error && ( + + {filteredSeries.length !== series.length && `${filteredSeries.length} of `} + {series.length} series + {paged && ' on this page'} + )} - - {error && ( -
- - - - Failed to load series: {error instanceof Error ? error.message : 'Unknown error'} + {paged && ( +
+ +
)} + {selectedSeries.length > 0 && ( + + )} + + ); + + return ( + + Series{' '} + + {selectedMetric} + + + } + controls={controls} + > +
+ {isLoading && } + + {error && } {!isLoading && !error && filteredSeries.length === 0 && (
@@ -114,77 +215,123 @@ export function SeriesTable() { {!isLoading && !error && filteredSeries.length > 0 && (
+ {Object.keys(shared).length > 0 && ( +
+ on every series + {Object.entries(shared).map(([key, value]) => ( + + {key}= + {value} + + ))} +
+ )} - + {columns.map((column) => ( + + ))} + {columns.length === 0 && } + {mixedTypes && } + - - - - {filteredSeries.map((s) => { - const checked = isSelected(s); + {sortedSeries.map((s) => { + const uuid = s['dct:identifier']; + const selected = selectedOf(s); + const labels = labelsToRecord(s['sensor:labels']); return ( handleToggle(s)} + onClick={() => toggleSeries(toSeriesInfo(s))} + onMouseEnter={() => setHoveredSeries(uuid)} + onMouseLeave={() => setHoveredSeries(null)} > - - - + ))} + {columns.length === 0 && ( + + )} + {mixedTypes && ( + + )} + ); })}
- Select + + {/* What is on screen, all at once: after the filter, or the selector, or on its own */} + { + if (box) box.indeterminate = visibleSelected > 0 && visibleSelected < sortedSeries.length; + }} + checked={visibleSelected === sortedSeries.length} + onChange={() => + visibleSelected === sortedSeries.length ? unselectSeries(sortedSeries.map(toSeriesInfo)) : selectSeries(sortedSeries.map(toSeriesInfo)) + } + /> + + + SeriesType + Series ID Series IDTypeLabels
+ {/* The checkbox is the color of the series in the chart */} handleToggle(s)} + className="checkbox checkbox-sm" + aria-label={`Select series ${uuid}`} + checked={!!selected} + style={ + selected + ? ({ + '--input-color': seriesColor(selected.slot, dark), + color: readableOn(seriesColor(selected.slot, dark)), + } as CSSProperties) + : undefined + } + onChange={() => toggleSeries(toSeriesInfo(s))} onClick={(e) => e.stopPropagation()} /> - {s['dct:identifier']} - - - {s['sensor:type']} - - -
- {(s['sensor:labels'] ?? []).map((labelObj, i) => - Object.entries(labelObj).map(([k, v]) => ( - - {k}= - {v} - - )) - )} - {(!s['sensor:labels'] || s['sensor:labels'].length === 0) && ( - no labels - )} -
+ {columns.map((column) => ( +
+ {labels[column] ?? —} + {dimensions.length === 0 ? 'no labels' : '—'} + {s['sensor:type'].toLowerCase()} + + {uuid}
-
- {filteredSeries.length} series - {filteredSeries.length !== series.length && ` (${series.length} total)`} -
)}
-
+
); } diff --git a/frontend/src/components/TimeRangeSelector.test.tsx b/frontend/src/components/TimeRangeSelector.test.tsx index 294e6f1a..a819e0b4 100644 --- a/frontend/src/components/TimeRangeSelector.test.tsx +++ b/frontend/src/components/TimeRangeSelector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, beforeEach } from 'vitest'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; @@ -12,13 +12,15 @@ describe('TimeRangeSelector', () => { start: '2025-01-01T00:00:00.000Z', end: '2025-01-01T01:00:00.000Z', }, + step: 'auto', + aggregation: 'avg', }); }); it('renders all preset buttons', () => { render(); for (const label of ['15m', '1h', '6h', '24h', '7d', '30d']) { - expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); } }); @@ -26,7 +28,7 @@ describe('TimeRangeSelector', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('1h')); + await user.click(screen.getByRole('button', { name: '1h' })); const { timeRange } = useSelectionStore.getState(); const diffMs = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime(); @@ -37,7 +39,7 @@ describe('TimeRangeSelector', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('24h')); + await user.click(screen.getByRole('button', { name: '24h' })); const { timeRange } = useSelectionStore.getState(); const diffMs = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime(); @@ -49,4 +51,85 @@ describe('TimeRangeSelector', () => { expect(screen.getByText('from')).toBeInTheDocument(); expect(screen.getByText('to')).toBeInTheDocument(); }); + + it('chooses the step: auto, raw or a duration', async () => { + const user = userEvent.setup(); + render(); + const step = screen.getByRole('combobox', { name: 'Step' }); + expect(step).toHaveValue('auto'); + + await user.selectOptions(step, '5m'); + expect(useSelectionStore.getState().step).toBe('5m'); + await user.selectOptions(step, 'raw'); + expect(useSelectionStore.getState().step).toBe('raw'); + }); + + it('chooses the aggregation, which has no use on raw data', async () => { + const user = userEvent.setup(); + render(); + const aggregation = screen.getByRole('combobox', { name: 'Aggregation' }); + expect(aggregation).toHaveValue('avg'); + expect(aggregation).toBeEnabled(); + + await user.selectOptions(aggregation, 'max'); + expect(useSelectionStore.getState().aggregation).toBe('max'); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Step' }), 'raw'); + expect(aggregation).toBeDisabled(); + }); + + it('marks the preset the range comes from, and none once dates are typed', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '24h' })); + expect(useSelectionStore.getState().relativeRange).toBe('24h'); + expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '1h' })).toHaveAttribute('aria-pressed', 'false'); + + act(() => useSelectionStore.getState().setTimeRange('2026-10-04T10:00:00.000Z', '2026-10-04T11:00:00.000Z')); + expect(useSelectionStore.getState().relativeRange).toBeNull(); + expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'false'); + }); + + describe('moving the window', () => { + const typed = { start: '2026-10-04T10:00:00.000Z', end: '2026-10-04T12:00:00.000Z' }; + const range = () => useSelectionStore.getState().timeRange; + + beforeEach(() => { + useSelectionStore.setState({ rangeHistory: [], relativeRange: null, timeRange: typed }); + }); + + it('goes earlier and later by half a window', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Earlier' })); + expect(range()).toEqual({ start: '2026-10-04T09:00:00.000Z', end: '2026-10-04T11:00:00.000Z' }); + await user.click(screen.getByRole('button', { name: 'Later' })); + expect(range()).toEqual(typed); + }); + + it('zooms out, and goes back', async () => { + const user = userEvent.setup(); + render(); + const back = screen.getByRole('button', { name: 'Back to the previous window' }); + expect(back).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Zoom out' })); + expect(range()).toEqual({ start: '2026-10-04T09:00:00.000Z', end: '2026-10-04T13:00:00.000Z' }); + expect(back).toBeEnabled(); + await user.click(back); + expect(range()).toEqual(typed); + expect(back).toBeDisabled(); + }); + + it('has no later for a live window: it is at now', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: '1h' })); + expect(screen.getByRole('button', { name: 'Later' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Earlier' })).toBeEnabled(); + }); + }); }); diff --git a/frontend/src/components/TimeRangeSelector.tsx b/frontend/src/components/TimeRangeSelector.tsx index 62f1e2d8..e579bc91 100644 --- a/frontend/src/components/TimeRangeSelector.tsx +++ b/frontend/src/components/TimeRangeSelector.tsx @@ -1,22 +1,23 @@ +import { AGGREGATIONS, FIXED_STEPS } from '../lib/chartStep'; +import type { Aggregation } from '../lib/chartStep'; +import { PRESETS } from '../lib/timeRange'; import { useSelectionStore } from '../stores/useSelectionStore'; -const PRESETS = [ - { label: '15m', minutes: 15 }, - { label: '1h', minutes: 60 }, - { label: '6h', minutes: 360 }, - { label: '24h', minutes: 1440 }, - { label: '7d', minutes: 10080 }, - { label: '30d', minutes: 43200 }, -]; - export function TimeRangeSelector() { - const { timeRange, setTimeRange } = useSelectionStore(); - - function handlePreset(minutes: number) { - const end = new Date(); - const start = new Date(end.getTime() - minutes * 60 * 1000); - setTimeRange(start.toISOString(), end.toISOString()); - } + const { + timeRange, + relativeRange, + rangeHistory, + setTimeRange, + setRelativeRange, + panRange, + zoomOutRange, + undoRange, + step, + setStep, + aggregation, + setAggregation, + } = useSelectionStore(); function handleStartChange(value: string) { if (value) { @@ -38,35 +39,107 @@ export function TimeRangeSelector() { } return ( -
+
+ {/* Where the window is: back to the one before, earlier, wider, later */} +
+ + + + +
+
{PRESETS.map((preset) => ( ))}
-
+
from handleStartChange(e.target.value)} /> to handleEndChange(e.target.value)} />
+ +
+ + +
); } diff --git a/frontend/src/components/TimeSeriesChart.test.tsx b/frontend/src/components/TimeSeriesChart.test.tsx new file mode 100644 index 00000000..1e882bea --- /dev/null +++ b/frontend/src/components/TimeSeriesChart.test.tsx @@ -0,0 +1,276 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { TimeSeriesChart } from './TimeSeriesChart'; +import { SERIES_COLORS } from '../lib/palette'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +const mockGetSeriesData = vi.fn(); +vi.mock('../client', () => ({ + getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), +})); + +const START = '2026-10-04T00:00:00.000Z'; +const hours = (n: number) => new Date(Date.parse(START) + n * 3600_000).toISOString(); + +const temperature = { uuid: 'uuid-temperature', name: 'temperature', labels: {}, type: 'float', slot: 0 }; +const state = { uuid: 'uuid-state', name: 'state', labels: {}, type: 'string', slot: 0 }; +const door = { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean', slot: 0 }; + +function renderChart() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +function queryOf(uuid: string) { + return mockGetSeriesData.mock.calls + .map(([options]) => options) + .find((options) => options.path.series_uuid === uuid)?.query; +} + +describe('TimeSeriesChart', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSelectionStore.setState({ step: 'auto', aggregation: 'avg', chartStyle: 'line', logScale: false }); + // SenML: the base time is in the first record, the others are relative to it + mockGetSeriesData.mockResolvedValue({ + data: [{ bt: 1_791_000_000, t: 0, v: 1 }, { t: 30, v: 2 }, { t: 60, v: 3 }], + }); + }); + + it('averages the numeric series of a wide range, the server refuses to send them raw', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(24) }, + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(queryOf('uuid-temperature')).toMatchObject({ step: '1m', aggregation: 'avg' }); + // Not the others: strings cannot be averaged + expect(queryOf('uuid-state')).not.toHaveProperty('step'); + expect(queryOf('uuid-state')).not.toHaveProperty('aggregation'); + }); + + it('sends the step and the aggregation that were chosen, to the numeric series only', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(1) }, + step: '5m', + aggregation: 'max', + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(queryOf('uuid-temperature')).toMatchObject({ step: '5m', aggregation: 'max' }); + expect(queryOf('uuid-state')).not.toHaveProperty('step'); + }); + + it('reads raw when asked to, even on a wide range', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(24) }, + step: 'raw', + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + expect(queryOf('uuid-temperature')).not.toHaveProperty('step'); + expect(queryOf('uuid-temperature')).not.toHaveProperty('aggregation'); + }); + + it('asks again when the step or the aggregation changes', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + step: '5m', + aggregation: 'avg', + }); + renderChart(); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + + act(() => useSelectionStore.getState().setAggregation('min')); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(mockGetSeriesData.mock.lastCall?.[0].query).toMatchObject({ aggregation: 'min' }); + + act(() => useSelectionStore.getState().setStep('1h')); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(3)); + expect(mockGetSeriesData.mock.lastCall?.[0].query).toMatchObject({ step: '1h' }); + }); + + it('reads a short range as it is', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(0.25) }, + }); + renderChart(); + + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + expect(queryOf('uuid-temperature')).toEqual({ + format: 'senml', + start: START, + end: hours(0.25), + }); + }); + + it('draws one line per series', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + expect(await screen.findByTestId('echarts')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + }); + + it('says which series failed, and draws the others', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + path.series_uuid === 'uuid-state' + ? { error: { error: 'Internal Server Error' }, response: { status: 500 } } + : { data: [{ bt: 1_791_000_000, t: 0, v: 1 }] }, + ), + ); + useSelectionStore.setState({ + selectedSeries: [temperature, state], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + expect(await screen.findByText(/Some series failed to load: Internal Server Error/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + }); + + it('draws a boolean as 0 and 1 on an axis of its own, and reads it raw', async () => { + mockGetSeriesData.mockResolvedValue({ + data: [{ bt: 1_791_000_000, t: 0, vb: true }, { t: 30, vb: false }, { t: 60, vb: true }], + }); + useSelectionStore.setState({ + selectedSeries: [door], + timeRange: { start: START, end: hours(24) }, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ step: 'end', yAxisIndex: 1 }); + expect(option.series[0].data.map(([, value]: [number, number]) => value)).toEqual([1, 0, 1]); + // A boolean cannot be averaged, even on a wide range + expect(queryOf('uuid-door')).not.toHaveProperty('step'); + }); + + it('draws with the style and the scale that were chosen', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + chartStyle: 'stacked', + logScale: true, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ stack: 'total' }); + expect(option.yAxis[0]).toMatchObject({ type: 'log' }); + }); + + it('keeps the series drawn while the data of a moved range is on its way', async () => { + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + await waitFor(() => expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1')); + + mockGetSeriesData.mockReturnValue(new Promise(() => {})); + act(() => useSelectionStore.getState().setTimeRange(hours(0.1), hours(1.1))); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + + expect(screen.getByTestId('echarts')).toHaveAttribute('data-series-count', '1'); + }); + + it('draws each series with the color of its slot, the one of its swatch in the list', async () => { + useSelectionStore.setState({ + selectedSeries: [{ ...temperature, slot: 3 }], + timeRange: { start: START, end: hours(1) }, + }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + const option = JSON.parse(chart.getAttribute('data-option')!); + expect(option.series[0]).toMatchObject({ id: 'uuid-temperature', color: SERIES_COLORS.light[3] }); + expect(option.legend).toBeUndefined(); + }); + + it('shows the window that was brushed on the chart, and reads it again', async () => { + const user = userEvent.setup(); + useSelectionStore.setState({ + selectedSeries: [temperature], + timeRange: { start: START, end: hours(1) }, + relativeRange: null, + }); + renderChart(); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(1)); + + await user.click(await screen.findByTestId('echarts-brush')); + + // In whole seconds, outwards + expect(useSelectionStore.getState().timeRange).toEqual({ + start: '2026-10-04T00:10:00.000Z', + end: '2026-10-04T00:20:01.000Z', + }); + expect(useSelectionStore.getState().relativeRange).toBeNull(); + await waitFor(() => expect(mockGetSeriesData).toHaveBeenCalledTimes(2)); + expect(mockGetSeriesData.mock.lastCall?.[0].query).toMatchObject({ + start: '2026-10-04T00:10:00.000Z', + end: '2026-10-04T00:20:01.000Z', + }); + }); + + it('says it is loading without moving the chart', async () => { + mockGetSeriesData.mockReturnValue(new Promise(() => {})); + useSelectionStore.setState({ selectedSeries: [temperature], timeRange: { start: START, end: hours(1) } }); + renderChart(); + + expect(await screen.findByRole('progressbar', { name: 'Loading chart data' })).toBeInTheDocument(); + expect(screen.getByTestId('echarts')).toBeInTheDocument(); + }); + + it('names a series by what tells it from the others', async () => { + const lab = { ...temperature, uuid: 'uuid-lab', labels: { org: 'sensapp', room: 'lab' } }; + const office = { ...temperature, uuid: 'uuid-office', slot: 1, labels: { org: 'sensapp', room: 'office' } }; + useSelectionStore.setState({ selectedSeries: [lab, office], timeRange: { start: START, end: hours(1) } }); + renderChart(); + + const chart = await screen.findByTestId('echarts'); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '2')); + const names = JSON.parse(chart.getAttribute('data-option')!).series.map((s: { name: string }) => s.name); + expect(names).toEqual(['temperature{room="lab"}', 'temperature{room="office"}']); + + // Alone, it says all it has + act(() => useSelectionStore.setState({ selectedSeries: [lab] })); + await waitFor(() => expect(chart).toHaveAttribute('data-series-count', '1')); + expect(JSON.parse(chart.getAttribute('data-option')!).series[0].name).toBe('temperature{org="sensapp", room="lab"}'); + }); + + it('highlights the series that the pointer is on in the list', async () => { + useSelectionStore.setState({ selectedSeries: [temperature], timeRange: { start: START, end: hours(1) }, hoveredSeries: null }); + renderChart(); + const chart = await screen.findByTestId('echarts'); + expect(chart).not.toHaveAttribute('data-highlight'); + + act(() => useSelectionStore.getState().setHoveredSeries('uuid-temperature')); + expect(screen.getByTestId('echarts')).toHaveAttribute('data-highlight', 'uuid-temperature'); + act(() => useSelectionStore.getState().setHoveredSeries(null)); + }); +}); diff --git a/frontend/src/components/TimeSeriesChart.tsx b/frontend/src/components/TimeSeriesChart.tsx index d393279b..2f549967 100644 --- a/frontend/src/components/TimeSeriesChart.tsx +++ b/frontend/src/components/TimeSeriesChart.tsx @@ -1,16 +1,17 @@ -import { useMemo } from 'react'; -import ReactECharts from 'echarts-for-react'; +import { lazy, Suspense, useMemo, useState } from 'react'; import { useQueries } from '@tanstack/react-query'; import { getSeriesData } from '../client'; import { useSelectionStore } from '../stores/useSelectionStore'; -import { extractErrorMessage } from '../api/clientConfig'; -import type { SenMLRecord } from '../hooks/useSeriesData'; +import { unwrap } from '../api/clientConfig'; +import { isBooleanType, isNumericType, resolveStep } from '../lib/chartStep'; +import { buildChartOption } from '../lib/chartOption'; +import { seriesColor } from '../lib/palette'; +import { sharedLabels, withoutShared } from '../lib/seriesLabels'; +import { brushedRange } from '../lib/timeRange'; +import { usePrefersDark } from '../lib/usePrefersDark'; -// Color palette for multiple series -const COLORS = [ - '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', - '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1', -]; +// echarts is large: load it when the first chart is drawn. +const EChart = lazy(() => import('./EChart')); function buildSeriesLabel(name: string, labels: Record): string { const labelStr = Object.entries(labels) @@ -19,23 +20,28 @@ function buildSeriesLabel(name: string, labels: Record): string return labelStr ? `${name}{${labelStr}}` : name; } +/** One SenML record. Only the first of a pack has the base time (`bt`), the others are relative to it. */ +interface SenMLRecord { + bt?: number; + t?: number; + v?: number; + vs?: string; + vb?: boolean; +} + function parseSenMLToTimeSeries( records: SenMLRecord[] ): Array<[number, number]> { - let baseName = ''; let baseTime = 0; const points: Array<[number, number]> = []; for (const rec of records) { - if (rec.bn !== undefined) baseName = rec.bn; if (rec.bt !== undefined) baseTime = rec.bt; - // We only care about re-assigning baseName for multi-record packs - void baseName; - const time = (baseTime + (rec.t ?? 0)) * 1000; // Convert to ms - const value = rec.v ?? (rec.vs ? parseFloat(rec.vs) : NaN); + // A boolean is 0 or 1 + const value = rec.vb !== undefined ? Number(rec.vb) : (rec.v ?? (rec.vs ? parseFloat(rec.vs) : NaN)); if (!isNaN(value) && isFinite(time)) { points.push([time, value]); @@ -45,12 +51,23 @@ function parseSenMLToTimeSeries( return points.sort((a, b) => a[0] - b[0]); } +/** What a query returns: the records of a series. */ +interface Loaded { + uuid: string; + records: SenMLRecord[]; +} + export function TimeSeriesChart() { - const { selectedSeries, timeRange } = useSelectionStore(); + const { selectedSeries, timeRange, setTimeRange, step: stepChoice, aggregation, chartStyle, logScale } = + useSelectionStore(); + const hoveredSeries = useSelectionStore((state) => state.hoveredSeries); + const dark = usePrefersDark(); + + const step = resolveStep(stepChoice, timeRange.start, timeRange.end); const queries = useQueries({ - queries: selectedSeries.map((s, index) => ({ - queryKey: ['seriesData', s.uuid, timeRange] as const, + queries: selectedSeries.map((s) => ({ + queryKey: ['seriesData', s.uuid, timeRange, step, aggregation] as const, queryFn: async () => { const result = await getSeriesData({ path: { series_uuid: s.uuid }, @@ -58,94 +75,49 @@ export function TimeSeriesChart() { format: 'senml', start: timeRange.start, end: timeRange.end, + // Only numbers can be aggregated. The server refuses more than 100 000 raw samples. + ...(step && isNumericType(s.type) ? { step, aggregation } : {}), }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return { - records: result.data as unknown as SenMLRecord[], - series: s, - colorIndex: index, - }; + return { uuid: s.uuid, records: unwrap(result) as unknown as SenMLRecord[] }; }, enabled: !!s.uuid, })), }); - const isLoading = queries.some((q) => q.isLoading); + const isFetching = queries.some((q) => q.isFetching); const errors = queries.filter((q) => q.error); + // The range moves every minute and a query has a key per range: the chart keeps what it shows + // until the data of the new range is there. (`placeholderData` cannot do it for `useQueries`.) + const [shown, setShown] = useState>({}); + const loaded = queries.flatMap((q) => (q.data ? [q.data] : [])); + if (loaded.some((data) => shown[data.uuid] !== data)) { + setShown({ ...shown, ...Object.fromEntries(loaded.map((data) => [data.uuid, data])) }); + } + const option = useMemo(() => { - const seriesData = queries - .filter((q) => q.data) - .map((q) => { - const { records, series, colorIndex } = q.data!; - const points = parseSenMLToTimeSeries(records); + // What every series has is not what tells them apart + const shared = sharedLabels(selectedSeries.map((s) => s.labels)); + const series = selectedSeries + .filter((s) => shown[s.uuid]) + .map((s) => { return { - name: buildSeriesLabel(series.name, series.labels), - type: 'line' as const, - data: points, - smooth: false, - showSymbol: points.length < 100, - symbol: 'circle', - symbolSize: 3, - lineStyle: { width: 1.5 }, - color: COLORS[colorIndex % COLORS.length], + id: s.uuid, + name: buildSeriesLabel(s.name, withoutShared(s.labels, shared)), + points: parseSenMLToTimeSeries(shown[s.uuid].records), + boolean: isBooleanType(s.type), + color: seriesColor(s.slot, dark), }; }); + return buildChartOption(series, chartStyle, logScale, timeRange); + }, [shown, selectedSeries, dark, timeRange, chartStyle, logScale]); - return { - tooltip: { - trigger: 'axis' as const, - axisPointer: { - type: 'cross' as const, - }, - }, - legend: { - data: seriesData.map((s) => s.name), - type: 'scroll' as const, - bottom: 0, - }, - grid: { - top: 40, - right: 40, - bottom: 60, - left: 60, - }, - xAxis: { - type: 'time' as const, - min: new Date(timeRange.start).getTime(), - max: new Date(timeRange.end).getTime(), - }, - yAxis: { - type: 'value' as const, - scale: true, - }, - dataZoom: [ - { - type: 'inside' as const, - start: 0, - end: 100, - }, - { - type: 'slider' as const, - start: 0, - end: 100, - bottom: 30, - height: 20, - }, - ], - toolbox: { - feature: { - saveAsImage: {}, - dataZoom: {}, - restore: {}, - }, - }, - series: seriesData, - }; - }, [queries, timeRange]); + // A drag on the chart is the window to show + function handleBrush(fromMs: number, toMs: number) { + const range = brushedRange(fromMs, toMs); + if (range) setTimeRange(range.start, range.end); + } if (selectedSeries.length === 0) { return null; @@ -153,25 +125,24 @@ export function TimeSeriesChart() { return (
- {isLoading && ( -
- - Loading chart data... -
- )} - {errors.length > 0 && (
Some series failed to load: {errors.map(q => q.error instanceof Error ? q.error.message : 'Unknown error').join(', ')}
)} - +
+ {/* Over the chart, so that the chart does not move when it loads */} + {isFetching && ( + + )} + + + +
); } diff --git a/frontend/src/hooks/useLiveRange.test.ts b/frontend/src/hooks/useLiveRange.test.ts new file mode 100644 index 00000000..aebb8521 --- /dev/null +++ b/frontend/src/hooks/useLiveRange.test.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { REFRESH_MS, useLiveRange } from './useLiveRange'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +describe('useLiveRange', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-05T10:00:00Z')); + useSelectionStore.getState().setRelativeRange('1h'); + }); + afterEach(() => vi.useRealTimers()); + + it('moves the end of a preset to now every minute', () => { + renderHook(() => useLiveRange()); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T10:00:00.000Z'); + + vi.advanceTimersByTime(REFRESH_MS); + const { timeRange, relativeRange } = useSelectionStore.getState(); + expect(timeRange).toEqual({ start: '2026-10-05T09:01:00.000Z', end: '2026-10-05T10:01:00.000Z' }); + expect(relativeRange).toBe('1h'); + }); + + it('moves it as soon as the tab is shown again', () => { + renderHook(() => useLiveRange()); + vi.setSystemTime(new Date('2026-10-05T12:00:00Z')); + document.dispatchEvent(new Event('visibilitychange')); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T12:00:00.000Z'); + }); + + it('leaves typed dates alone', () => { + const typed = { start: '2026-10-01T00:00:00.000Z', end: '2026-10-02T00:00:00.000Z' }; + useSelectionStore.getState().setTimeRange(typed.start, typed.end); + renderHook(() => useLiveRange()); + + vi.advanceTimersByTime(3 * REFRESH_MS); + expect(useSelectionStore.getState().timeRange).toEqual(typed); + }); + + it('stops when it is unmounted', () => { + const { unmount } = renderHook(() => useLiveRange()); + unmount(); + vi.advanceTimersByTime(REFRESH_MS); + expect(useSelectionStore.getState().timeRange.end).toBe('2026-10-05T10:00:00.000Z'); + }); +}); diff --git a/frontend/src/hooks/useLiveRange.ts b/frontend/src/hooks/useLiveRange.ts new file mode 100644 index 00000000..68b28b7c --- /dev/null +++ b/frontend/src/hooks/useLiveRange.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useSelectionStore } from '../stores/useSelectionStore'; + +export const REFRESH_MS = 60_000; + +/** + * A preset such as `1h` is the last hour, so its end is now: it moves on every minute while the + * tab is on screen, and at once when the tab comes back. Dates that were typed do not move. + */ +export function useLiveRange() { + const relativeRange = useSelectionStore((state) => state.relativeRange); + + useEffect(() => { + if (!relativeRange) return; + const refresh = () => { + if (!document.hidden) useSelectionStore.getState().refreshRelativeRange(); + }; + const timer = setInterval(refresh, REFRESH_MS); + document.addEventListener('visibilitychange', refresh); + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }; + }, [relativeRange]); +} diff --git a/frontend/src/hooks/useMetrics.ts b/frontend/src/hooks/useMetrics.ts index f49d22e7..252ce23b 100644 --- a/frontend/src/hooks/useMetrics.ts +++ b/frontend/src/hooks/useMetrics.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { listMetrics } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; export interface DcatDataset { '@type': string; @@ -34,13 +34,18 @@ export interface DcatCatalog { 'dcat:dataset': DcatDataset[]; } -export function useMetrics(filters?: { - name?: string; - nameRegex?: string; - type?: string; -}) { +export function useMetrics( + filters?: { + name?: string; + nameRegex?: string; + type?: string; + }, + // `false` when the catalog is not needed, nor worth a refusal that asks for a token + options?: { enabled?: boolean }, +) { return useQuery({ queryKey: ['metrics', filters], + enabled: options?.enabled ?? true, queryFn: async () => { const result = await listMetrics({ query: { @@ -49,10 +54,7 @@ export function useMetrics(filters?: { type: filters?.type, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as DcatCatalog; + return unwrap(result) as unknown as DcatCatalog; }, }); } diff --git a/frontend/src/hooks/useSeries.ts b/frontend/src/hooks/useSeries.ts index 00ef9e97..3dab76b9 100644 --- a/frontend/src/hooks/useSeries.ts +++ b/frontend/src/hooks/useSeries.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { listSeries } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; +import { unwrap } from '../api/clientConfig'; export interface SeriesLabel { [key: string]: string; @@ -36,9 +36,17 @@ export interface SeriesCatalog { }; } +/** The cursor of the next page, in the `hydra:next` link of a page. */ +export function nextBookmark(catalog?: SeriesCatalog): string | undefined { + const next = catalog?.['hydra:view']?.['hydra:next']; + if (!next) return undefined; + return new URL(next, 'http://sensapp').searchParams.get('bookmark') ?? undefined; +} + export function useSeries(filters?: { metric?: string; selector?: string; + bookmark?: string; }) { return useQuery({ queryKey: ['series', filters], @@ -47,12 +55,10 @@ export function useSeries(filters?: { query: { metric: filters?.metric, selector: filters?.selector, + bookmark: filters?.bookmark, }, }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as SeriesCatalog; + return unwrap(result) as unknown as SeriesCatalog; }, enabled: !!filters?.metric || !!filters?.selector, }); diff --git a/frontend/src/hooks/useSeriesData.ts b/frontend/src/hooks/useSeriesData.ts deleted file mode 100644 index 5c889260..00000000 --- a/frontend/src/hooks/useSeriesData.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getSeriesData } from '../client'; -import { extractErrorMessage } from '../api/clientConfig'; - -export interface SenMLRecord { - bn?: string; - bt?: number; - bu?: string; - n?: string; - t?: number; - u?: string; - v?: number; - vs?: string; - vb?: boolean; - vd?: string; -} - -export function useSeriesData( - seriesUuid: string | undefined, - options?: { - start?: string; - end?: string; - format?: string; - limit?: number; - } -) { - return useQuery({ - queryKey: ['seriesData', seriesUuid, options], - queryFn: async () => { - const result = await getSeriesData({ - path: { series_uuid: seriesUuid! }, - query: { - format: options?.format || 'senml', - start: options?.start, - end: options?.end, - limit: options?.limit, - }, - }); - if (result.error) { - throw new Error(extractErrorMessage(result.error)); - } - return result.data as unknown as SenMLRecord[]; - }, - enabled: !!seriesUuid, - }); -} diff --git a/frontend/src/hooks/useUrlState.test.tsx b/frontend/src/hooks/useUrlState.test.tsx new file mode 100644 index 00000000..ae403004 --- /dev/null +++ b/frontend/src/hooks/useUrlState.test.tsx @@ -0,0 +1,162 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { createQueryClient } from '../api/queryClient'; +import { useAuthStore } from '../stores/useAuthStore'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import { useUrlState } from './useUrlState'; + +const mockGetSeriesData = vi.fn(); +vi.mock('../client', () => ({ + getSeriesData: (...args: unknown[]) => mockGetSeriesData(...args), +})); + +function Probe() { + useUrlState(); + const { search } = useLocation(); + return {search}; +} + +function renderAt(url: string) { + const queryClient = createQueryClient(); + queryClient.setDefaultOptions({ queries: { staleTime: 30_000, retry: false } }); + const view = render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + return { ...view, queryClient }; +} + +const search = () => new URLSearchParams(screen.getByTestId('search').textContent ?? ''); + +/** The first record of a series, as SensApp sends it. */ +const firstRecord = (uuid: string) => + uuid === 'uuid-door' + ? { _name: 'door', bn: uuid, bt: 1, t: 0, vb: true } + : { _name: 'temperature', _labels: { room: 'lab' }, bn: uuid, bt: 1, t: 0, v: 21.5 }; + +describe('useUrlState', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + useSelectionStore.setState({ + selectedMetric: null, + selectedSeries: [], + relativeRange: '1h', + chartStyle: 'line', + logScale: false, + step: 'auto', + aggregation: 'avg', + }); + mockGetSeriesData.mockImplementation(({ path }) => Promise.resolve({ data: [firstRecord(path.series_uuid)] })); + }); + + it('keeps the plain address of the plain explorer', async () => { + renderAt('/'); + await waitFor(() => expect(screen.getByTestId('search')).toBeInTheDocument()); + expect(screen.getByTestId('search')).toHaveTextContent(''); + expect(mockGetSeriesData).not.toHaveBeenCalled(); + }); + + it('shows what the address says', async () => { + renderAt('/?metric=temperature&series=uuid-t&series=uuid-door&range=24h&step=5m&agg=max&style=area&log=1'); + + await waitFor(() => expect(useSelectionStore.getState().selectedSeries).toHaveLength(2)); + const state = useSelectionStore.getState(); + expect(state).toMatchObject({ + selectedMetric: 'temperature', + relativeRange: '24h', + step: '5m', + aggregation: 'max', + chartStyle: 'area', + logScale: true, + }); + // The name, the labels and the type come from the first sample of each series, a color slot each + expect(state.selectedSeries).toEqual([ + { uuid: 'uuid-t', name: 'temperature', labels: { room: 'lab' }, type: 'float', slot: 0 }, + { uuid: 'uuid-door', name: 'door', labels: {}, type: 'boolean', slot: 1 }, + ]); + const hours = (Date.parse(state.timeRange.end) - Date.parse(state.timeRange.start)) / 3_600_000; + expect(hours).toBe(24); + }); + + it('shows an absolute range as it is', async () => { + renderAt('/?from=2026-10-04T10:00:00.000Z&to=2026-10-04T11:00:00.000Z'); + await waitFor(() => expect(useSelectionStore.getState().relativeRange).toBeNull()); + expect(useSelectionStore.getState().timeRange).toEqual({ + start: '2026-10-04T10:00:00.000Z', + end: '2026-10-04T11:00:00.000Z', + }); + }); + + it('keeps the address in step with the explorer, and not in the history', async () => { + renderAt('/?series=uuid-t&metric=temperature'); + await waitFor(() => expect(useSelectionStore.getState().selectedSeries).toHaveLength(1)); + + act(() => { + useSelectionStore.getState().setStep('1h'); + useSelectionStore.getState().setChartStyle('bars'); + useSelectionStore.getState().setRelativeRange('7d'); + }); + await waitFor(() => expect(search().get('range')).toBe('7d')); + expect(search().get('step')).toBe('1h'); + expect(search().get('style')).toBe('bars'); + expect(search().getAll('series')).toEqual(['uuid-t']); + expect(search().get('metric')).toBe('temperature'); + + act(() => useSelectionStore.getState().clearSelectedSeries()); + await waitFor(() => expect(search().has('series')).toBe(false)); + }); + + it('writes the dates once they were typed', async () => { + renderAt('/'); + await waitFor(() => expect(screen.getByTestId('search')).toBeInTheDocument()); + act(() => useSelectionStore.getState().setTimeRange('2026-10-04T10:00:00.000Z', '2026-10-04T11:00:00.000Z')); + await waitFor(() => expect(search().get('from')).toBe('2026-10-04T10:00:00.000Z')); + expect(search().has('range')).toBe(false); + }); + + it('forgets a series that is gone, and the nonsense of the address', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + path.series_uuid === 'uuid-gone' + ? { error: { error: 'not found' }, response: { status: 404 } } + : { data: [firstRecord(path.series_uuid)] }, + ), + ); + renderAt('/?series=uuid-gone&series=uuid-t&step=7x&style=pie'); + await waitFor(() => expect(useSelectionStore.getState().selectedSeries.map((s) => s.uuid)).toEqual(['uuid-t'])); + expect(useSelectionStore.getState()).toMatchObject({ step: 'auto', chartStyle: 'line' }); + await waitFor(() => expect(search().getAll('series')).toEqual(['uuid-t'])); + expect(search().has('step')).toBe(false); + }); + + it('asks for a token on a shared link, then shows the series', async () => { + mockGetSeriesData.mockImplementation(({ path }) => + Promise.resolve( + useAuthStore.getState().token + ? { data: [firstRecord(path.series_uuid)] } + : { error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }, + ), + ); + const { queryClient } = renderAt('/?series=uuid-t'); + + await waitFor(() => expect(useAuthStore.getState().dialogOpen).toBe(true)); + // Nothing is written while the series are unknown: the link must not be lost + expect(search().getAll('series')).toEqual(['uuid-t']); + expect(useSelectionStore.getState().selectedSeries).toEqual([]); + + // What the token dialog does + act(() => useAuthStore.getState().signIn('a.token.here')); + await act(() => queryClient.invalidateQueries()); + + await waitFor(() => expect(useSelectionStore.getState().selectedSeries.map((s) => s.uuid)).toEqual(['uuid-t'])); + expect(search().getAll('series')).toEqual(['uuid-t']); + }); +}); diff --git a/frontend/src/hooks/useUrlState.ts b/frontend/src/hooks/useUrlState.ts new file mode 100644 index 00000000..4c7f0156 --- /dev/null +++ b/frontend/src/hooks/useUrlState.ts @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import { getSeriesData } from '../client'; +import { ApiError, unwrap } from '../api/clientConfig'; +import { fromSearchParams, toSearchParams } from '../lib/urlState'; +import { rangeFor } from '../lib/timeRange'; +import { useSelectionStore } from '../stores/useSelectionStore'; +import { withSlots } from '../stores/useSelectionStore'; +import type { SeriesInfo } from '../stores/useSelectionStore'; + +/** The type of a series, from the value its first record carries. */ +function typeOf(record: Record): string { + if ('vb' in record) return 'boolean'; + if ('vs' in record) return 'string'; + if ('v' in record) return 'float'; + return 'other'; +} + +/** + * What the chart needs to know of a series that an address names by its uuid: its first sample + * carries the name and the labels. `null` for a series that is gone. + */ +async function probeSeries(uuid: string): Promise { + try { + const result = await getSeriesData({ + path: { series_uuid: uuid }, + query: { format: 'senml', limit: 1 }, + }); + const first = (unwrap(result) as unknown as Array>)[0]; + if (!first) return null; + return { + uuid, + name: typeof first._name === 'string' ? first._name : uuid, + labels: (first._labels ?? {}) as Record, + type: typeOf(first), + }; + } catch (error) { + // Asking for a token is the job of the query cache, anything else is a series to forget + if (error instanceof ApiError && !error.isAuthError) return null; + throw error; + } +} + +/** + * The explorer in the address: what the address says is shown when the page opens, then the + * address follows what is shown (it replaces the history entry, a click is not a page). + */ +export function useUrlState() { + const [params, setParams] = useSearchParams(); + const [initial] = useState(() => fromSearchParams(params)); + + const setParamsRef = useRef(setParams); + useEffect(() => { + setParamsRef.current = setParams; + }); + + // Everything but the series is known at once + useEffect(() => { + const range = initial.relativeRange ? rangeFor(initial.relativeRange) : initial.timeRange; + useSelectionStore.setState({ + ...(initial.metric && { selectedMetric: initial.metric }), + ...(initial.relativeRange !== undefined && { relativeRange: initial.relativeRange }), + ...(range && { timeRange: range }), + ...(initial.step && { step: initial.step }), + ...(initial.aggregation && { aggregation: initial.aggregation }), + ...(initial.chartStyle && { chartStyle: initial.chartStyle }), + ...(initial.logScale && { logScale: true }), + }); + }, [initial]); + + // The series need a request each. It is a query: a token that is asked for is then used. + const probes = useQueries({ + queries: (initial.series ?? []).map((uuid) => ({ + queryKey: ['seriesProbe', uuid], + queryFn: () => probeSeries(uuid), + staleTime: Infinity, + })), + }); + const settled = probes.every( + (probe) => probe.isSuccess || (probe.isError && !(probe.error instanceof ApiError && probe.error.isAuthError)), + ); + + const probesRef = useRef(probes); + useEffect(() => { + probesRef.current = probes; + }); + + // Once the series are known: show them, and from then on the address follows the explorer + useEffect(() => { + if (!settled) return; + const series = probesRef.current.flatMap((probe) => (probe.data ? [probe.data] : [])); + if (series.length > 0) useSelectionStore.setState({ selectedSeries: withSlots(series) }); + + const write = (state: ReturnType) => + setParamsRef.current( + toSearchParams({ + metric: state.selectedMetric, + series: state.selectedSeries.map((s) => s.uuid), + relativeRange: state.relativeRange, + timeRange: state.timeRange, + step: state.step, + aggregation: state.aggregation, + chartStyle: state.chartStyle, + logScale: state.logScale, + }), + { replace: true }, + ); + write(useSelectionStore.getState()); + return useSelectionStore.subscribe(write); + }, [settled]); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 49cd2852..d9fe284c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,44 +1,235 @@ @import "tailwindcss"; -@import "@fontsource/roboto"; -@import "@fontsource/roboto/500.css"; -@import "@fontsource/roboto/700.css"; + +/* One variable file for every weight, latin only (the UI is in English). */ +@font-face { + font-family: "Roboto Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2") format("woff2-variations"); +} + +/* The display face, for the few words of the header that are not the logo. */ +@font-face { + font-family: "Sora Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url("@fontsource-variable/sora/files/sora-latin-wght-normal.woff2") format("woff2-variations"); +} @plugin "daisyui" { - themes: light --default, dark; + themes: false; logs: false; } +/* + * The colors come from the logo: its navy blue is the primary color, the grey of its dish the + * neutral one. The surfaces have a hint of that blue. The colors of the series are another matter + * (src/lib/palette.ts): they have to be told apart, not to match the logo. + */ @plugin "daisyui/theme" { name: "light"; default: true; color-scheme: light; - --color-primary: #2563eb; + --color-base-100: #ffffff; + --color-base-200: #f3f6fa; + --color-base-300: #dce4ed; + --color-base-content: #17222e; + --color-primary: #1d4f7f; --color-primary-content: #ffffff; - --color-secondary: #7c3aed; + --color-secondary: #5d6f83; --color-secondary-content: #ffffff; - --color-accent: #059669; + --color-accent: #0f7f86; --color-accent-content: #ffffff; - --color-neutral: #1f2937; - --color-base-100: #ffffff; - --color-base-200: #f8fafc; - --color-base-300: #e2e8f0; + --color-neutral: #1c2b3a; + --color-neutral-content: #f3f6fa; + --color-info: #2f78b8; + --color-info-content: #ffffff; + --color-success: #1f8a4c; + --color-success-content: #ffffff; + --color-warning: #d99a00; + --color-warning-content: #1f1600; + --color-error: #c93c3c; + --color-error-content: #ffffff; +} + +@plugin "daisyui/theme" { + name: "dark"; + prefersdark: true; + color-scheme: dark; + --color-base-100: #1d232a; + --color-base-200: #151a20; + --color-base-300: #2d3641; + --color-base-content: #e4eaf1; + --color-primary: #6aa8de; + --color-primary-content: #0b1a2a; + --color-secondary: #8fa0b3; + --color-secondary-content: #10161d; + --color-accent: #3cb4b8; + --color-accent-content: #08191a; + --color-neutral: #2d3641; + --color-neutral-content: #e4eaf1; + --color-info: #5aa0e0; + --color-info-content: #08182a; + --color-success: #3dbb78; + --color-success-content: #06180e; + --color-warning: #e0a919; + --color-warning-content: #1f1600; + --color-error: #e36767; + --color-error-content: #240808; } @theme { - --font-sans: Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-display: "Sora Variable", "Roboto Variable", ui-sans-serif, system-ui, sans-serif; + --font-code: "JetBrains Mono Variable", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --font-sans: "Roboto Variable", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } +/* The page scrolls on small screens. From lg on it is one screen, and the panels scroll. */ html, body, #root { - height: 100%; - overflow: hidden; + min-height: 100%; +} + +@media (min-width: 1024px) { + html, body, #root { + height: 100%; + overflow: hidden; + } +} + +/* + * One look for the controls, in both themes. The borders are a hint of the text color, so they follow the + * theme (daisyUI's outline buttons and badges are drawn in the full text color: white frames on the dark one). + * Written outside the layers: they must win over daisyUI's. + */ +:root { + --control-border: color-mix(in oklab, var(--color-base-content) 18%, transparent); + --control-border-hover: color-mix(in oklab, var(--color-base-content) 32%, transparent); + --wash: color-mix(in oklab, var(--color-base-content) 7%, transparent); +} + +/* A button of the bars: quiet, and tinted with the primary color when it is on */ +.btn.btn-quiet { + background: transparent; + box-shadow: none; + background-image: none; + border-color: var(--control-border); + color: color-mix(in oklab, var(--color-base-content) 80%, transparent); +} +/* + * A joined button overlaps its neighbor by one border, so the shared edge is one line. daisyUI skips that + * overlap on a disabled button, which then shows a double edge on its left: the line jumps as the state changes. + */ +.join > .btn.btn-quiet:not(:first-child) { + margin-inline-start: calc(var(--border, 1px) * -1); +} +.btn.btn-quiet:hover:not(:disabled) { + background: var(--wash); + border-color: var(--control-border-hover); +} +.btn.btn-quiet:disabled { + background: transparent; + /* daisyUI drops the end border of a disabled joined button (the last one loses its right edge) */ + border-inline-end-width: var(--border, 1px); + border-color: var(--control-border); + color: color-mix(in oklab, var(--color-base-content) 30%, transparent); +} +.btn.btn-quiet.btn-active, +.btn.btn-quiet[aria-pressed="true"] { + background: color-mix(in oklab, var(--color-primary) 16%, transparent); + border-color: color-mix(in oklab, var(--color-primary) 55%, transparent); + color: var(--color-primary); +} + +/* The ghost buttons (API Docs, Clear) get the wash of the quiet ones, nothing else */ +.btn.btn-ghost:hover:not(:disabled) { + background: var(--wash); + border-color: transparent; + box-shadow: none; +} + +/* A label or a dimension: a wash, no frame */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.125rem; + border-radius: 0.25rem; + padding: 0.0625rem 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + background: var(--wash); + color: color-mix(in oklab, var(--color-base-content) 75%, transparent); + white-space: nowrap; +} + +/* The fields share the border of the buttons */ +.input, +.select { + border-color: var(--control-border); + box-shadow: none; +} +.input:hover, +.select:hover { + border-color: var(--control-border-hover); +} +.input:focus, +.input:focus-within, +.select:focus { + border-color: var(--color-primary); +} + +/* Thin scrollbars that are not brighter than the page */ +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklab, var(--color-base-content) 28%, transparent) transparent; } -/* Smooth transitions for interactive elements */ -.transition-card { - transition: box-shadow 0.2s ease, border-color 0.2s ease; +/* The face of code, loaded only when a snippet is shown. */ +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url("@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations"); } -/* Better table row hover */ -tr.cursor-pointer:hover td { - background-color: oklch(var(--color-primary) / 0.04); +/* + * Code is always on a dark ground, in both themes: the colors do not change with the theme, so they + * are written once (the palette is One Dark's, on a navy that sits with the logo's). + */ +.code-block { + --code-bg: #0f1722; + --code-fg: #d6deeb; + background: var(--code-bg); + color: var(--code-fg); + font-family: var(--font-code); + font-size: 0.8125rem; + line-height: 1.65; + font-variant-ligatures: none; + tab-size: 4; + /* Long lines wrap (no sideways scroll); a token with no space, such as a uuid or an address, breaks where it must */ + white-space: pre-wrap; + overflow-wrap: anywhere; } +.code-block .hljs-comment { color: #8a98aa; font-style: italic; } +.code-block .hljs-keyword, +.code-block .hljs-doctag { color: #c792ea; } +.code-block .hljs-string, +.code-block .hljs-regexp { color: #a5d38a; } +.code-block .hljs-number, +.code-block .hljs-literal { color: #f0a972; } +.code-block .hljs-built_in, +.code-block .hljs-type { color: #e8c874; } +.code-block .hljs-title, +.code-block .hljs-title.function_ { color: #7ab8f5; } +.code-block .hljs-title.class_ { color: #e8c874; } +.code-block .hljs-variable, +.code-block .hljs-template-variable, +.code-block .hljs-params { color: #ef8b94; } +.code-block .hljs-attr, +.code-block .hljs-attribute, +.code-block .hljs-meta, +.code-block .hljs-symbol { color: #5fc8cf; } +.code-block .hljs-operator, +.code-block .hljs-punctuation { color: #9fb0c4; } diff --git a/frontend/src/lib/chartOption.test.ts b/frontend/src/lib/chartOption.test.ts new file mode 100644 index 00000000..209352c2 --- /dev/null +++ b/frontend/src/lib/chartOption.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { buildChartOption } from './chartOption'; +import type { ChartSeries } from './chartOption'; + +const RANGE = { start: '2026-10-04T00:00:00.000Z', end: '2026-10-04T01:00:00.000Z' }; +const temperature: ChartSeries = { id: 'uuid-t', name: 'temperature', points: [[1, 20], [2, 21]], boolean: false, color: '#111' }; +const door: ChartSeries = { id: 'uuid-d', name: 'door', points: [[1, 0], [2, 1]], boolean: true, color: '#222' }; + +type Option = { series: Array>; yAxis: Array>; grid: { right: number } }; +const build = (series: ChartSeries[], style: Parameters[1] = 'line', log = false) => + buildChartOption(series, style, log, RANGE) as unknown as Option; + +describe('buildChartOption', () => { + it('draws a line by default, over the time range', () => { + const option = build([temperature]); + expect(option.series[0]).toMatchObject({ type: 'line', name: 'temperature', stack: undefined, areaStyle: undefined, step: undefined }); + expect(buildChartOption([temperature], 'line', false, RANGE)).toMatchObject({ + xAxis: { min: Date.parse(RANGE.start), max: Date.parse(RANGE.end) }, + }); + }); + + it('has no legend (the list of series has the colors), and tells its series by id', () => { + const option = build([temperature, door]) as unknown as { legend?: unknown }; + expect(option.legend).toBeUndefined(); + expect(build([temperature, door]).series.map((s) => s.id)).toEqual(['uuid-t', 'uuid-d']); + }); + + it('has no zoom of its own: the window is the explorer\'s, a drag on the chart asks for another', () => { + const option = buildChartOption([temperature], 'line', false, RANGE) as unknown as { + dataZoom?: unknown; + brush: Record; + }; + expect(option.dataZoom).toBeUndefined(); + expect(option.brush).toMatchObject({ xAxisIndex: 0, brushType: 'lineX', brushMode: 'single' }); + }); + + it('knows its styles', () => { + expect(build([temperature], 'step').series[0]).toMatchObject({ type: 'line', step: 'end' }); + expect(build([temperature], 'area').series[0]).toMatchObject({ type: 'line', areaStyle: { opacity: 0.2 } }); + expect(build([temperature], 'stacked').series[0]).toMatchObject({ type: 'line', stack: 'total', areaStyle: {} }); + expect(build([temperature], 'bars').series[0]).toMatchObject({ type: 'bar', showSymbol: false }); + }); + + it('switches the axis to a logarithmic scale', () => { + expect(build([temperature]).yAxis[0]).toMatchObject({ type: 'value', scale: true }); + expect(build([temperature], 'line', true).yAxis[0]).toMatchObject({ type: 'log', scale: false }); + }); + + it('draws booleans as a step line on an axis of their own, in every style', () => { + for (const style of ['line', 'area', 'stacked', 'bars'] as const) { + const option = build([temperature, door], style); + expect(option.series[1]).toMatchObject({ type: 'line', step: 'end', yAxisIndex: 1 }); + expect(option.series[1]).not.toHaveProperty('stack'); + expect(option.series[1]).not.toHaveProperty('areaStyle'); + expect(option.yAxis).toHaveLength(2); + expect(option.yAxis[1]).toMatchObject({ min: 0, max: 1 }); + expect(option.grid.right).toBe(60); + } + }); + + it('has no axis for booleans without booleans', () => { + const option = build([temperature]); + expect(option.yAxis).toHaveLength(1); + expect(option.grid.right).toBe(40); + }); + + it('labels the boolean axis', () => { + const formatter = (build([door]).yAxis[1].axisLabel as { formatter: (v: number) => string }).formatter; + expect([formatter(0), formatter(1)]).toEqual(['false', 'true']); + }); +}); diff --git a/frontend/src/lib/chartOption.ts b/frontend/src/lib/chartOption.ts new file mode 100644 index 00000000..ba6cdcc7 --- /dev/null +++ b/frontend/src/lib/chartOption.ts @@ -0,0 +1,104 @@ +import type { EChartsCoreOption } from 'echarts/core'; + +export type ChartStyle = 'line' | 'step' | 'area' | 'stacked' | 'bars'; +export const CHART_STYLES: ChartStyle[] = ['line', 'step', 'area', 'stacked', 'bars']; + +export interface ChartSeries { + /** What echarts tells the series by when the option changes: a series that stays is not redrawn */ + id: string; + name: string; + points: Array<[number, number]>; + /** Drawn as a 0/1 step line on an axis of its own, whatever the style */ + boolean: boolean; + color: string; +} + +/** The echarts option of the time series chart. */ +export function buildChartOption( + series: ChartSeries[], + style: ChartStyle, + logScale: boolean, + range: { start: string; end: string }, +): EChartsCoreOption { + const hasBoolean = series.some((s) => s.boolean); + + const lines = series.map((s) => { + if (s.boolean) { + return { + id: s.id, + name: s.name, + type: 'line' as const, + step: 'end' as const, + yAxisIndex: 1, + data: s.points, + emphasis: { focus: 'series' as const }, + showSymbol: false, + lineStyle: { width: 1.5 }, + color: s.color, + }; + } + return { + id: s.id, + name: s.name, + type: style === 'bars' ? ('bar' as const) : ('line' as const), + data: s.points, + // The series under the pointer in the list stands out, the others fade (EChart sends it) + emphasis: { focus: 'series' as const }, + smooth: false, + step: style === 'step' ? ('end' as const) : undefined, + stack: style === 'stacked' ? 'total' : undefined, + areaStyle: style === 'area' || style === 'stacked' ? { opacity: 0.2 } : undefined, + showSymbol: style !== 'bars' && s.points.length < 100, + symbol: 'circle', + symbolSize: 3, + lineStyle: { width: 1.5 }, + color: s.color, + }; + }); + + return { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + // Long names must not leave the chart, and 23.99055599 is 24 for the eye + confine: true, + valueFormatter: (value: unknown) => (typeof value === 'number' ? String(Number(value.toPrecision(6))) : String(value ?? '-')), + }, + grid: { top: 40, right: hasBoolean ? 60 : 40, bottom: 30, left: 60 }, + xAxis: { + type: 'time', + min: new Date(range.start).getTime(), + max: new Date(range.end).getTime(), + }, + yAxis: [ + // A log axis cannot start from the data + { type: logScale ? 'log' : 'value', scale: !logScale }, + ...(hasBoolean + ? [ + { + type: 'value', + min: 0, + max: 1, + interval: 1, + splitLine: { show: false }, + axisLabel: { formatter: (value: number) => (value ? 'true' : 'false') }, + }, + ] + : []), + ], + // The time window is the one of the explorer, and a drag on the chart asks for another (EChart + // listens to it): there is no zoom of the chart on its own, which would zoom into points that + // were cut for the window before. + brush: { + xAxisIndex: 0, + brushType: 'lineX', + brushMode: 'single', + transformable: false, + removeOnClick: true, + brushStyle: { borderWidth: 1, color: 'rgba(100, 150, 220, 0.15)', borderColor: 'rgba(100, 150, 220, 0.7)' }, + }, + // echarts brings buttons for the brush with it (a drag does it, and the header says so) + toolbox: { show: false }, + series: lines, + }; +} diff --git a/frontend/src/lib/chartStep.test.ts b/frontend/src/lib/chartStep.test.ts new file mode 100644 index 00000000..0ad13948 --- /dev/null +++ b/frontend/src/lib/chartStep.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { chartStep, isNumericType, resolveStep } from './chartStep'; + +const START = '2026-10-04T00:00:00.000Z'; +const at = (seconds: number) => new Date(Date.parse(START) + seconds * 1000).toISOString(); + +describe('chartStep', () => { + it('reads short ranges as they are', () => { + expect(chartStep(START, at(15 * 60))).toBeUndefined(); + expect(chartStep(START, at(2000))).toBeUndefined(); + expect(chartStep(START, at(3600))).toBeUndefined(); + expect(chartStep(START, at(10_000))).toBeUndefined(); + }); + + it('aggregates the ranges of the presets of the UI', () => { + expect(chartStep(START, at(10_001))).toBe('10s'); + expect(chartStep(START, at(6 * 3600))).toBe('15s'); + expect(chartStep(START, at(24 * 3600))).toBe('1m'); + expect(chartStep(START, at(7 * 86400))).toBe('10m'); + expect(chartStep(START, at(30 * 86400))).toBe('30m'); + }); + + it('never makes more than 2000 points, up to a year', () => { + const seconds = (step: string) => parseInt(step) * { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[step.slice(-1) as 's']!; + for (const days of [1, 3, 10, 45, 90, 180, 365]) { + const step = chartStep(START, at(days * 86400))!; + expect((days * 86400) / seconds(step)).toBeLessThanOrEqual(2000); + } + }); + + it('uses the longest step for a range beyond the table', () => { + expect(chartStep(START, at(20 * 365 * 86400))).toBe('1w'); + }); + + it('reads a range that makes no sense as it is', () => { + expect(chartStep(at(100), START)).toBeUndefined(); + expect(chartStep('not a date', START)).toBeUndefined(); + }); +}); + +describe('isNumericType', () => { + it('knows what can be averaged', () => { + for (const type of ['float', 'Float', 'integer', 'numeric']) expect(isNumericType(type)).toBe(true); + for (const type of ['string', 'boolean', 'location', 'json', 'blob']) expect(isNumericType(type)).toBe(false); + }); +}); + +describe('resolveStep', () => { + it('lets the range decide on auto', () => { + expect(resolveStep('auto', START, at(24 * 3600))).toBe('1m'); + expect(resolveStep('auto', START, at(3600))).toBeUndefined(); + }); + + it('does not aggregate raw', () => { + expect(resolveStep('raw', START, at(30 * 86400))).toBeUndefined(); + }); + + it('takes a fixed step as it is, whatever the range', () => { + expect(resolveStep('5m', START, at(3600))).toBe('5m'); + }); +}); diff --git a/frontend/src/lib/chartStep.ts b/frontend/src/lib/chartStep.ts new file mode 100644 index 00000000..dfbba25f --- /dev/null +++ b/frontend/src/lib/chartStep.ts @@ -0,0 +1,64 @@ +/** Longest raw read of `GET /series/{uuid}` is 100 000 samples: wide ranges must be aggregated. */ +const MAX_POINTS = 2000; + +/** Below this step the samples are read as they are: averaging 5 seconds hides nothing. */ +const MIN_STEP_SECONDS = 5; + +/** Round steps, in seconds, with the Prometheus duration that says them. */ +const STEPS: Array<[number, string]> = [ + [5, '5s'], + [10, '10s'], + [15, '15s'], + [30, '30s'], + [60, '1m'], + [120, '2m'], + [300, '5m'], + [600, '10m'], + [900, '15m'], + [1800, '30m'], + [3600, '1h'], + [7200, '2h'], + [10800, '3h'], + [21600, '6h'], + [43200, '12h'], + [86400, '1d'], + [604800, '1w'], +]; + +const NUMERIC_TYPES = ['float', 'integer', 'numeric']; + +/** Only numbers can be averaged: the server refuses (or fails) on the other types. */ +export function isNumericType(type: string): boolean { + return NUMERIC_TYPES.includes(type.toLowerCase()); +} + +/** + * The `step` that keeps a chart of this range under `MAX_POINTS` points, or `undefined` when the + * range is short enough (under about 2.8 hours) to be read as it is. The samples of a step are + * averaged. + */ +export function chartStep(start: string, end: string): string | undefined { + const seconds = (new Date(end).getTime() - new Date(start).getTime()) / 1000; + if (!(seconds > MAX_POINTS)) return undefined; + const wanted = seconds / MAX_POINTS; + if (wanted <= MIN_STEP_SECONDS) return undefined; + const [, step] = STEPS.find(([length]) => length >= wanted) ?? STEPS[STEPS.length - 1]; + return step; +} + +/** What the step selector offers besides `auto` (the range decides) and `raw` (no aggregation). */ +export const FIXED_STEPS = ['5s', '15s', '30s', '1m', '5m', '15m', '30m', '1h', '6h', '1d']; + +export const AGGREGATIONS = ['avg', 'min', 'max', 'sum', 'count', 'first', 'last'] as const; +export type Aggregation = (typeof AGGREGATIONS)[number]; + +/** The `step` to send for the choice of the selector: `auto`, `raw`, or a duration. */ +export function resolveStep(choice: string, start: string, end: string): string | undefined { + if (choice === 'raw') return undefined; + if (choice === 'auto') return chartStep(start, end); + return choice; +} + +export function isBooleanType(type: string): boolean { + return type.toLowerCase() === 'boolean'; +} diff --git a/frontend/src/lib/copyText.ts b/frontend/src/lib/copyText.ts new file mode 100644 index 00000000..2d6a49ea --- /dev/null +++ b/frontend/src/lib/copyText.ts @@ -0,0 +1,28 @@ +/** + * Puts the text on the clipboard. The async API only exists on a secure page (https, localhost), and + * SensApp is often reached by plain http on a network: the old way works there. `false` when neither does. + */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // refused (permissions, an iframe): try the other way + } + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + area.remove(); + } +} diff --git a/frontend/src/lib/credentials.test.ts b/frontend/src/lib/credentials.test.ts new file mode 100644 index 00000000..ee2f34be --- /dev/null +++ b/frontend/src/lib/credentials.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { ADMIN_TOKEN_COMMAND, addSensor, shellWord, tokenCommand, tokenScopes } from './credentials'; + +describe('addSensor', () => { + it('adds a name and keeps the order', () => { + expect(addSensor(addSensor([], 'temperature'), 'humidity')).toEqual(['temperature', 'humidity']); + }); + + it('keeps a name exactly as typed, commas and spaces included', () => { + expect(addSensor([], 'cpu,usage')).toEqual(['cpu,usage']); + expect(addSensor([], ' padded ')).toEqual([' padded ']); + expect(addSensor(['cpu usage_idle'], 'cpu usage_idle')).toEqual(['cpu usage_idle']); + }); + + it('does not add a blank name nor one that is there already', () => { + const sensors = ['temperature']; + expect(addSensor(sensors, '')).toBe(sensors); + expect(addSensor(sensors, ' ')).toBe(sensors); + expect(addSensor(sensors, 'temperature')).toBe(sensors); + }); +}); + +describe('tokenScopes', () => { + it('lists what the token claims', () => { + expect(tokenScopes({ scope: 'read write delete' })).toEqual(['read', 'write', 'delete']); + expect(tokenScopes({ scope: 'admin' })).toEqual(['admin']); + }); + + it('knows the default of a token without the claim, and of one that cannot be read', () => { + expect(tokenScopes({})).toEqual(['read', 'write']); + expect(tokenScopes(null)).toEqual(['read', 'write']); + }); +}); + +describe('shellWord', () => { + it('leaves a plain word bare and quotes the rest', () => { + expect(shellWord('edge-device_7')).toBe('edge-device_7'); + expect(shellWord('cpu usage')).toBe("'cpu usage'"); + expect(shellWord('cpu,usage')).toBe("'cpu,usage'"); + expect(shellWord("it's")).toBe(`'it'\\''s'`); + // Nothing a name holds can add a command + expect(shellWord('x; rm -rf ~')).toBe("'x; rm -rf ~'"); + expect(shellWord('$(whoami)')).toBe("'$(whoami)'"); + }); +}); + +describe('ADMIN_TOKEN_COMMAND', () => { + it('makes a token that can read as well, so that the explorer does not ask for another', () => { + expect(ADMIN_TOKEN_COMMAND).toBe('sensapp generate-token me --scope read,admin'); + }); +}); + +describe('tokenCommand', () => { + const wish = { subject: 'edge-7', scope: ['read', 'write'], sensors: [], durationSeconds: 86400 }; + + it('is one line without sensors', () => { + expect(tokenCommand(wish)).toBe('sensapp generate-token edge-7 --scope read,write --duration 86400'); + }); + + it('gives each sensor a --sensor of its own, commas kept in the name', () => { + expect(tokenCommand({ ...wish, sensors: ['temperature', 'cpu,usage', 'mem used'] })).toBe( + "sensapp generate-token edge-7 --scope read,write --duration 86400 \\\n --sensor temperature \\\n --sensor 'cpu,usage' \\\n --sensor 'mem used'", + ); + }); + + it('quotes the name of the token', () => { + expect(tokenCommand({ ...wish, subject: "bob's phone" })).toContain(`'bob'\\''s phone'`); + }); + + it('says what is still to choose', () => { + expect(tokenCommand({ ...wish, subject: ' ', scope: [] })).toBe('sensapp generate-token NAME --scope SCOPE --duration 86400'); + }); + + it('can make an admin token, which the endpoint cannot', () => { + expect(tokenCommand({ ...wish, scope: ['admin'], durationSeconds: 3600 })).toBe('sensapp generate-token edge-7 --scope admin --duration 3600'); + }); +}); diff --git a/frontend/src/lib/credentials.ts b/frontend/src/lib/credentials.ts new file mode 100644 index 00000000..536ab86b --- /dev/null +++ b/frontend/src/lib/credentials.ts @@ -0,0 +1,52 @@ +import type { TokenInfo } from '../stores/useAuthStore'; + +/** + * What makes a token that can make tokens. The `admin` scope reads nothing, and the endpoint never + * makes another admin token. `read` is added for the person who signs in with it: the explorer + * would otherwise ask for another token. + */ +export const ADMIN_TOKEN_COMMAND = 'sensapp generate-token me --scope read,admin'; + +/** What the form asks for. */ +export interface TokenWish { + subject: string; + scope: string[]; + /** Names as they are: a name may hold a comma or a space */ + sensors: string[]; + durationSeconds: number; +} + +/** + * The sensors with a name added. A name is kept exactly as typed (the server matches it exactly, + * commas and spaces included): only a blank name, or one that is there already, is not added. + */ +export function addSensor(sensors: string[], name: string): string[] { + if (name.trim() === '' || sensors.includes(name)) return sensors; + return [...sensors, name]; +} + +/** The scopes a token says it has. A token without the claim has the default one of the server. */ +export function tokenScopes(info: TokenInfo | null): string[] { + return info?.scope === undefined ? ['read', 'write'] : info.scope.split(/\s+/).filter(Boolean); +} + +/** A shell word that is one word whatever it contains, left bare when it is plain. */ +export function shellWord(value: string): string { + return /^[A-Za-z0-9_@%+=:./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * The command that makes what the form describes, for `sensapp generate-token`: the way to make an + * admin token, and to make any token where only the command line reaches the secret. Each sensor + * is a `--sensor` of its own, as a name may hold a comma. + */ +export function tokenCommand(wish: TokenWish): string { + const line = [ + 'sensapp generate-token', + wish.subject.trim() === '' ? 'NAME' : shellWord(wish.subject), + `--scope ${wish.scope.length > 0 ? wish.scope.join(',') : 'SCOPE'}`, + `--duration ${wish.durationSeconds}`, + ].join(' '); + if (wish.sensors.length === 0) return line; + return [line, ...wish.sensors.map((name) => `--sensor ${shellWord(name)}`)].join(' \\\n '); +} diff --git a/frontend/src/lib/highlight.ts b/frontend/src/lib/highlight.ts new file mode 100644 index 00000000..7cc4adde --- /dev/null +++ b/frontend/src/lib/highlight.ts @@ -0,0 +1,18 @@ +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import ini from 'highlight.js/lib/languages/ini'; +import python from 'highlight.js/lib/languages/python'; +import yaml from 'highlight.js/lib/languages/yaml'; + +hljs.registerLanguage('python', python); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('ini', ini); +hljs.registerLanguage('yaml', yaml); + +/** The names highlight.js knows the languages of the page by (it reads TOML as ini). */ +const NAMES = { python: 'python', bash: 'bash', toml: 'ini', yaml: 'yaml' } as const; + +/** The code as HTML with its colors. highlight.js escapes the text it is given: the result is safe to put in the page. */ +export function highlight(code: string, language: keyof typeof NAMES): string { + return hljs.highlight(code, { language: NAMES[language] }).value; +} diff --git a/frontend/src/lib/loadSnippets.test.ts b/frontend/src/lib/loadSnippets.test.ts new file mode 100644 index 00000000..86def9a6 --- /dev/null +++ b/frontend/src/lib/loadSnippets.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { LOAD_WAYS, sectionsFor } from './loadSnippets'; +import type { LoadInput } from './loadSnippets'; + +const open: LoadInput = { baseUrl: 'http://sensapp.example:3000', authenticated: false }; +const secured: LoadInput = { ...open, authenticated: true }; + +const codeOf = (way: Parameters[0], input: LoadInput) => sectionsFor(way, input).map((s) => s.code).join('\n'); + +describe('load snippets', () => { + it('say where the server is, in every way', () => { + for (const way of LOAD_WAYS) { + expect(codeOf(way.id, open), way.id).toContain('sensapp.example:3000'); + } + }); + + it('never carry a token, and read it from the environment only when the server asks for one', () => { + for (const way of LOAD_WAYS) { + expect(codeOf(way.id, open), way.id).not.toContain('SENSAPP_TOKEN'); + const code = codeOf(way.id, secured); + // Prometheus reads a file, the others the environment + expect(code, way.id).toContain(way.id === 'prometheus' ? 'generate-token prometheus --scope readwrite' : 'SENSAPP_TOKEN'); + if (way.id !== 'prometheus') expect(code, way.id).toContain('generate-token me --scope write'); + expect(code, way.id).not.toMatch(/eyJ/); + } + }); + + it('python: three scripts that tell uv what they need, each opening the client', () => { + const sections = sectionsFor('python', open); + expect(sections.map((s) => s.title)).toEqual(['DataFrame', 'One sample at a time', 'Batches']); + for (const section of sections) { + expect(section.language).toBe('python'); + expect(section.code.startsWith('# /// script')).toBe(true); + expect(section.code).toContain('async with SensAppClient("http://sensapp.example:3000") as client:'); + } + expect(sections[0].code).toContain('frame.iter_slices(100_000)'); + expect(sections[2].code).toContain('SamplePoint(datetime.now(UTC)'); + }); + + it('open with a token section, the same for every way, that links to Credentials', () => { + for (const way of LOAD_WAYS) { + expect(sectionsFor(way.id, open).map((s) => s.title), way.id).not.toContain('A token'); + const [first] = sectionsFor(way.id, secured); + expect(first.title, way.id).toBe('A token'); + expect(first.link, way.id).toEqual({ to: '/credentials', label: 'Make a token in Credentials' }); + // The other sections do not repeat how to make one + for (const section of sectionsFor(way.id, secured).slice(1)) { + expect(section.code, way.id).not.toContain('generate-token'); + } + } + }); + + it('python: the client takes the token of the environment', () => { + const code = codeOf('python', secured); + expect(code).toContain('import os'); + expect(code).toContain('token=os.environ["SENSAPP_TOKEN"],'); + }); + + it('telegraf: gives its token to Telegraf, which sends it the InfluxDB way', () => { + expect(codeOf('telegraf', open)).toContain('urls = ["http://sensapp.example:3000"]'); + expect(codeOf('telegraf', open)).not.toContain('token'); + expect(codeOf('telegraf', secured)).toContain('token = "${SENSAPP_TOKEN}"'); + // SensApp reads the Token scheme of Telegraf: no header to rewrite + expect(codeOf('telegraf', secured)).not.toContain('http_headers'); + }); + + it('prometheus: writes and reads', () => { + const code = codeOf('prometheus', open); + expect(code).toContain('url: http://sensapp.example:3000/api/v1/prometheus_remote_write'); + expect(code).toContain('url: http://sensapp.example:3000/api/v1/prometheus_remote_read'); + expect(codeOf('prometheus', secured)).toContain('credentials_file'); + }); + + it('curl: puts every address in quotes and the token in a header', () => { + const code = codeOf('curl', secured); + expect(code).toContain("'http://sensapp.example:3000/publish'"); + expect(code).toContain("'http://sensapp.example:3000/api/v2/write?org=sensapp&bucket=home&precision=s'"); + expect(code).toContain('-H "Authorization: Bearer $SENSAPP_TOKEN"'); + }); +}); diff --git a/frontend/src/lib/loadSnippets.ts b/frontend/src/lib/loadSnippets.ts new file mode 100644 index 00000000..445e6cd0 --- /dev/null +++ b/frontend/src/lib/loadSnippets.ts @@ -0,0 +1,300 @@ +import { SCRIPT_METADATA } from './snippets'; + +/** What the snippets need to know: where SensApp is, and whether it asks for a token. */ +export interface LoadInput { + /** The origin of the SensApp server */ + baseUrl: string; + /** The server asked for a token: the snippets read it from `SENSAPP_TOKEN`, they never contain one */ + authenticated: boolean; +} + +export type LoadLanguage = 'python' | 'bash' | 'toml' | 'yaml'; + +/** A piece of the page: a title, a sentence, and the code that goes with it. */ +export interface LoadSection { + title: string; + description: string; + language: LoadLanguage; + code: string; + /** A page of the UI that goes with the section */ + link?: { to: string; label: string }; +} + +export const LOAD_WAYS = [ + { id: 'python', label: 'Python SDK' }, + { id: 'telegraf', label: 'Telegraf' }, + { id: 'prometheus', label: 'Prometheus' }, + { id: 'curl', label: 'curl' }, +] as const; +export type LoadWay = (typeof LOAD_WAYS)[number]['id']; + +/** What makes a token that can write. The read one of the Code dialog cannot. */ +export const WRITE_TOKEN_COMMAND = 'sensapp generate-token me --scope write'; + +/** Where a token is made in the UI. */ +const CREDENTIALS_LINK = { to: '/credentials', label: 'Make a token in Credentials' }; + +/** + * The first section of a way when the server asks for a token: where the code finds it, and how to + * make one. The code never contains a token, it reads `SENSAPP_TOKEN`. + */ +function tokenSection(input: LoadInput): LoadSection[] { + if (!input.authenticated) return []; + return [ + { + title: 'A token', + description: + 'This server asks for a token. The code reads it from `SENSAPP_TOKEN`. Make one with the `write` scope in the Credentials tab, or with the command line, and keep it in the environment.', + language: 'bash', + code: `export SENSAPP_TOKEN=$(${WRITE_TOKEN_COMMAND})\n`, + link: CREDENTIALS_LINK, + }, + ]; +} + +/** A Python string literal. A JSON string is one, with its escapes. */ +const py = (value: string) => JSON.stringify(value); + +/** A shell word that is one word whatever it contains. */ +const sh = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +/** The client of the SDK, as `async with` opens it, indented by `indent`. */ +function pythonClient(input: LoadInput, indent: string): string[] { + return input.authenticated + ? [`${indent}async with SensAppClient(`, `${indent} ${py(input.baseUrl)},`, `${indent} token=os.environ["SENSAPP_TOKEN"],`, `${indent}) as client:`] + : [`${indent}async with SensAppClient(${py(input.baseUrl)}) as client:`]; +} + +/** The head of a script: the block `uv run` reads, then the imports. */ +function pythonHead(file: string, imports: string[]): string[] { + return [ + ...SCRIPT_METADATA, + '#', + `# Run it with \`uv run ${file}\``, + '', + ...imports, + ]; +} + +export function pythonSections(input: LoadInput): LoadSection[] { + const os = input.authenticated ? ['import os'] : []; + + const frame = [ + ...pythonHead('load.py', ['import asyncio', 'from datetime import UTC, datetime, timedelta', ...os, '', 'import polars as pl', 'from sensapp import SensAppClient']), + '', + '# One row per sample: "timestamp" (with a time zone) and "value".', + '# Or read yours: pl.read_csv("data.csv", try_parse_dates=True), pl.read_parquet(...), pl.from_pandas(df).', + '# A timestamp with no time zone needs .dt.replace_time_zone("UTC").', + 'start = datetime.now(UTC) - timedelta(days=30)', + 'frame = pl.DataFrame(', + ' {', + ' "timestamp": [start + timedelta(minutes=i) for i in range(43_200)],', + ' "value": [20 + (i % 1440) / 100 for i in range(43_200)],', + ' }', + ')', + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' # Slices keep each request small', + ' for part in frame.iter_slices(100_000):', + ' await client.publish("temperature", part)', + '', + '', + 'asyncio.run(main())', + ]; + + const one = [ + ...pythonHead('sample.py', ['import asyncio', ...os, '', 'from sensapp import SensAppClient']), + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' # The timestamp is the time of the call', + ' await client.publish("temperature", 21.5)', + '', + '', + 'asyncio.run(main())', + ]; + + const few = [ + ...pythonHead('stream.py', ['import asyncio', 'import random', 'from datetime import UTC, datetime', ...os, '', 'from sensapp import SamplePoint, SensAppClient']), + '', + '', + 'def read_temperature() -> float:', + ' return 20 + random.random() # your sensor here', + '', + '', + 'async def main() -> None:', + ...pythonClient(input, ' '), + ' batch: list[SamplePoint] = []', + ' while True:', + ' # Each sample keeps the time it was read', + ' batch.append(SamplePoint(datetime.now(UTC), read_temperature()))', + ' if len(batch) >= 10:', + ' await client.publish("temperature", batch)', + ' batch.clear()', + ' await asyncio.sleep(1)', + '', + '', + 'asyncio.run(main())', + ]; + + return [ + ...tokenSection(input), + { + title: 'DataFrame', + description: 'Send a table of timestamps and values. Publishing the same name again appends to the same series.', + language: 'python', + code: frame.join('\n') + '\n', + }, + { + title: 'One sample at a time', + description: 'The sample is stamped with the time of the call.', + language: 'python', + code: one.join('\n') + '\n', + }, + { + title: 'Batches', + description: 'Keep the samples and send ten at a time: fewer requests than one per sample.', + language: 'python', + code: few.join('\n') + '\n', + }, + ]; +} + +export function telegrafSections(input: LoadInput): LoadSection[] { + const config = [ + '[agent]', + ' interval = "10s"', + ' flush_interval = "10s"', + '', + '# SensApp speaks the InfluxDB v2 write API', + '[[outputs.influxdb_v2]]', + ` urls = [${py(input.baseUrl)}]`, + ' organization = "sensapp"', + ' bucket = "telegraf"', + ' content_encoding = "gzip"', + ' influx_uint_support = true', + ...(input.authenticated + ? [ + ' token = "${SENSAPP_TOKEN}"', + ] + : []), + '', + '# Something to measure', + '[[inputs.cpu]]', + ' percpu = true', + ' totalcpu = true', + '', + '[[inputs.mem]]', + '', + '[[inputs.disk]]', + ' ignore_fs = ["tmpfs", "devtmpfs", "devfs", "overlay", "squashfs"]', + ]; + return [ + ...tokenSection(input), + { + title: 'telegraf.conf', + description: + 'Telegraf writes through the InfluxDB v2 API. The measurement and the field make the series name (`cpu usage_idle`), the tags become labels, and `organization` and `bucket` are kept as the labels `influxdb_org` and `influxdb_bucket`.', + language: 'toml', + code: config.join('\n') + '\n', + }, + { + title: 'Run it', + description: 'One collection to test, then the daemon.', + language: 'bash', + code: ['telegraf --config telegraf.conf --once', 'telegraf --config telegraf.conf'].join('\n') + '\n', + }, + ]; +} + +const PROMETHEUS_TOKEN_FILE = '/etc/prometheus/sensapp.token'; + +/** Prometheus reads its token from a file, for writing and for reading. */ +const PROMETHEUS_TOKEN_COMMAND = `sensapp generate-token prometheus --scope readwrite --duration 31536000 > ${PROMETHEUS_TOKEN_FILE}`; + +export function prometheusSections(input: LoadInput): LoadSection[] { + const authorization = input.authenticated + ? [' authorization:', ` credentials_file: ${PROMETHEUS_TOKEN_FILE}`] + : []; + const config = [ + 'remote_write:', + ' - name: sensapp', + ` url: ${input.baseUrl}/api/v1/prometheus_remote_write`, + ...authorization, + '', + 'remote_read:', + ' - name: sensapp', + ` url: ${input.baseUrl}/api/v1/prometheus_remote_read`, + ' read_recent: true', + ...authorization, + ]; + return [ + ...(input.authenticated + ? [ + { + title: 'A token', + description: + 'This server asks for a token. Prometheus reads it from a file, not from the environment. Make one that can read and write, and lasts a year, in the Credentials tab (and write it to the file), or with the command line:', + language: 'bash' as const, + code: PROMETHEUS_TOKEN_COMMAND + '\n', + link: CREDENTIALS_LINK, + }, + ] + : []), + { + title: 'prometheus.yml', + description: + '`remote_write` sends every scraped sample to SensApp, `remote_read` queries it back. Labels are kept, the metric name is the series name. `read_recent` reads recent data from SensApp too, not only what local storage no longer has.', + language: 'yaml', + code: config.join('\n') + '\n', + }, + { + title: 'Prometheus in a container', + description: 'In a container `localhost` is the container: use `host.docker.internal`, or the name of the SensApp service.', + language: 'yaml', + code: ['remote_write:', ' - url: http://host.docker.internal:3000/api/v1/prometheus_remote_write', ''].join('\n'), + }, + ]; +} + +export function curlSections(input: LoadInput): LoadSection[] { + const header = input.authenticated ? ['-H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; + /** One request: each option on its own line, the address last. */ + const request = (options: string[], url: string) => ['curl -sS --fail-with-body \\', ...[...header, ...options].map((line) => ` ${line} \\`), ` ${url}`]; + const publish = sh(`${input.baseUrl}/publish`); + const write = sh(`${input.baseUrl}/api/v2/write?org=sensapp&bucket=home&precision=s`); + + const senml = [ + ...request(['--json ' + sh('[{"n":"temperature","u":"Cel","v":21.5},{"n":"humidity","u":"%RH","v":41,"t":1767225600}]')], publish), + ]; + const csv = [ + "printf 'datetime,sensor_name,value,unit\\n2026-01-01T12:00:00Z,temperature,21.5,Cel\\n2026-01-01T12:01:00Z,temperature,21.7,Cel\\n' > measurements.csv", + '', + ...request(['-H ' + sh('content-type: text/csv'), '--data-binary @measurements.csv'], publish), + ]; + const line = [ + ...request(['--data-binary ' + sh('weather,location=oslo temperature=21.5,humidity=41i 1767225600')], write), + ]; + return [ + ...tokenSection(input), + { title: 'SenML JSON', description: 'RFC 8428. `n` is the name, `u` the unit, `v` the value, `t` a Unix time in seconds (now when missing).', language: 'bash', code: senml.join('\n') + '\n' }, + { title: 'CSV', description: 'The columns are found by name: a datetime, a `sensor_name`, a `value`, and optionally a `unit`.', language: 'bash', code: csv.join('\n') + '\n' }, + { title: 'InfluxDB line protocol', description: '`measurement,tag=value field=value timestamp`. The tags become labels. `org` and `bucket` are required, `precision=s` makes the timestamp seconds.', language: 'bash', code: line.join('\n') + '\n' }, + ]; +} + +export function sectionsFor(way: LoadWay, input: LoadInput): LoadSection[] { + switch (way) { + case 'python': + return pythonSections(input); + case 'telegraf': + return telegrafSections(input); + case 'prometheus': + return prometheusSections(input); + case 'curl': + return curlSections(input); + } +} diff --git a/frontend/src/lib/names.test.ts b/frontend/src/lib/names.test.ts new file mode 100644 index 00000000..2f58d76d --- /dev/null +++ b/frontend/src/lib/names.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { randomName } from './names'; + +describe('randomName', () => { + it('is two adjectives, a scientist and a number, joined by dashes', () => { + for (let i = 0; i < 200; i++) expect(randomName()).toMatch(/^[a-z]+-[a-z]+-[a-z]+-\d{1,2}$/); + }); + + it('is made of what the random numbers pick', () => { + expect(randomName(() => 0)).toBe('admiring-adoring-agnesi-0'); + }); + + it('does not repeat an adjective', () => { + for (let i = 0; i < 200; i++) { + const [first, second] = randomName().split('-'); + expect(first).not.toBe(second); + } + }); +}); diff --git a/frontend/src/lib/names.ts b/frontend/src/lib/names.ts new file mode 100644 index 00000000..aeb157e3 --- /dev/null +++ b/frontend/src/lib/names.ts @@ -0,0 +1,50 @@ +/** + * Names for a token, as Docker makes them for its containers: two adjectives, a scientist and a number. + * The words are those of the list of moby (internal/namesgenerator), Apache 2.0. + */ + +const ADJECTIVES = [ + 'admiring', 'adoring', 'affectionate', 'agitated', 'amazing', 'angry', 'awesome', 'beautiful', 'blissful', 'bold', 'boring', 'brave', + 'busy', 'charming', 'clever', 'compassionate', 'competent', 'condescending', 'confident', 'cool', 'cranky', 'crazy', 'dazzling', + 'determined', 'distracted', 'dreamy', 'eager', 'ecstatic', 'elastic', 'elated', 'elegant', 'eloquent', 'epic', 'exciting', 'fervent', + 'festive', 'flamboyant', 'focused', 'friendly', 'frosty', 'funny', 'gallant', 'gifted', 'goofy', 'gracious', 'great', 'happy', 'hardcore', + 'heuristic', 'hopeful', 'hungry', 'infallible', 'inspiring', 'intelligent', 'interesting', 'jolly', 'jovial', 'keen', 'kind', 'laughing', + 'loving', 'lucid', 'magical', 'modest', 'musing', 'mystifying', 'naughty', 'nervous', 'nice', 'nifty', 'nostalgic', 'objective', + 'optimistic', 'peaceful', 'pedantic', 'pensive', 'practical', 'priceless', 'quirky', 'quizzical', 'recursing', 'relaxed', 'reverent', + 'romantic', 'sad', 'serene', 'sharp', 'silly', 'sleepy', 'stoic', 'strange', 'stupefied', 'suspicious', 'sweet', 'tender', 'thirsty', + 'trusting', 'unruffled', 'upbeat', 'vibrant', 'vigilant', 'vigorous', 'wizardly', 'wonderful', 'xenodochial', 'youthful', 'zealous', 'zen', +]; + +const NOUNS = [ + 'agnesi', 'albattani', 'allen', 'almeida', 'antonelli', 'archimedes', 'ardinghelli', 'aryabhata', 'austin', 'babbage', 'banach', 'banzai', + 'bardeen', 'bartik', 'bassi', 'beaver', 'bell', 'benz', 'bhabha', 'bhaskara', 'black', 'blackburn', 'blackwell', 'bohr', 'booth', 'borg', + 'bose', 'bouman', 'boyd', 'brahmagupta', 'brattain', 'brown', 'buck', 'burnell', 'cannon', 'carson', 'cartwright', 'carver', 'cerf', + 'chandrasekhar', 'chaplygin', 'chatelet', 'chatterjee', 'chaum', 'chebyshev', 'clarke', 'cohen', 'colden', 'cori', 'cray', 'curie', + 'curran', 'darwin', 'davinci', 'dewdney', 'dhawan', 'diffie', 'dijkstra', 'dirac', 'driscoll', 'dubinsky', 'easley', 'edison', 'einstein', + 'elbakyan', 'elgamal', 'elion', 'ellis', 'engelbart', 'euclid', 'euler', 'faraday', 'feistel', 'fermat', 'fermi', 'feynman', 'franklin', + 'gagarin', 'galileo', 'galois', 'ganguly', 'gates', 'gauss', 'germain', 'goldberg', 'goldstine', 'goldwasser', 'golick', 'goodall', + 'gould', 'greider', 'grothendieck', 'haibt', 'hamilton', 'haslett', 'hawking', 'heisenberg', 'hellman', 'hermann', 'herschel', 'hertz', + 'heyrovsky', 'hodgkin', 'hofstadter', 'hoover', 'hopper', 'hugle', 'hypatia', 'ishizaka', 'jackson', 'jang', 'jemison', 'jennings', + 'jepsen', 'johnson', 'joliot', 'jones', 'kalam', 'kapitsa', 'kare', 'keldysh', 'keller', 'kepler', 'khayyam', 'khorana', 'kilby', 'kirch', + 'knuth', 'kowalevski', 'lalande', 'lamarr', 'lamport', 'leakey', 'leavitt', 'lederberg', 'lehmann', 'lewin', 'lichterman', 'liskov', + 'lovelace', 'lumiere', 'mahavira', 'margulis', 'matsumoto', 'maxwell', 'mayer', 'mccarthy', 'mcclintock', 'mclaren', 'mclean', 'mcnulty', + 'meitner', 'mendel', 'mendeleev', 'meninsky', 'merkle', 'mestorf', 'mirzakhani', 'montalcini', 'moore', 'morse', 'moser', 'murdock', + 'napier', 'nash', 'neumann', 'newton', 'nightingale', 'nobel', 'noether', 'northcutt', 'noyce', 'panini', 'pare', 'pascal', 'pasteur', + 'payne', 'perlman', 'pike', 'poincare', 'poitras', 'proskuriakova', 'ptolemy', 'raman', 'ramanujan', 'rhodes', 'ride', 'ritchie', + 'robinson', 'roentgen', 'rosalind', 'rubin', 'saha', 'sammet', 'sanderson', 'satoshi', 'shamir', 'shannon', 'shaw', 'shirley', 'shockley', + 'shtern', 'sinoussi', 'snyder', 'solomon', 'spence', 'stonebraker', 'sutherland', 'swanson', 'swartz', 'swirles', 'taussig', 'tesla', + 'tharp', 'thompson', 'torvalds', 'tu', 'turing', 'varahamihira', 'vaughan', 'villani', 'visvesvaraya', 'volhard', 'wescoff', 'wilbur', + 'wiles', 'williams', 'williamson', 'wilson', 'wing', 'wozniak', 'wright', 'wu', 'yalow', 'yonath', 'zhukovsky', +]; + +const pick = (words: readonly string[], random: () => number) => words[Math.floor(random() * words.length)]; + +/** `brave-quirky-curie-42`. A name is never made of the same adjective twice. */ +export function randomName(random: () => number = Math.random): string { + const first = pick(ADJECTIVES, random); + const second = pick( + ADJECTIVES.filter((word) => word !== first), + random, + ); + return `${first}-${second}-${pick(NOUNS, random)}-${Math.floor(random() * 100)}`; +} diff --git a/frontend/src/lib/palette.test.ts b/frontend/src/lib/palette.test.ts new file mode 100644 index 00000000..896b6a12 --- /dev/null +++ b/frontend/src/lib/palette.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { DISTINCT_COLORS, freeSlot, readableOn, SERIES_COLORS, seriesColor, SLOTS } from './palette'; + +describe('palette', () => { + it('has a step of each hue for each theme', () => { + expect(SERIES_COLORS.light).toHaveLength(SLOTS); + expect(SERIES_COLORS.dark).toHaveLength(SLOTS); + expect(seriesColor(0, false)).not.toBe(seriesColor(0, true)); + }); + + it('gives the first slot nobody uses', () => { + expect(freeSlot([])).toBe(0); + expect(freeSlot([0, 1])).toBe(2); + expect(freeSlot([0, 2])).toBe(1); + }); + + it('uses the colors again when they are all taken', () => { + const all = Array.from({ length: SLOTS }, (_, i) => i); + expect(freeSlot(all)).toBe(0); + expect(seriesColor(SLOTS + 1, false)).toBe(seriesColor(1, false)); + }); + + it('writes in dark on the light colors and in white on the others', () => { + expect(readableOn('#eda100')).toBe('#111827'); + expect(readableOn('#2a78d6')).toBe('#ffffff'); + }); + + it('has more colors than eight, none twice, so that more than eight series do not repeat one', () => { + expect(SLOTS).toBeGreaterThan(DISTINCT_COLORS); + expect(new Set(SERIES_COLORS.light).size).toBe(SLOTS); + expect(new Set(SERIES_COLORS.dark).size).toBe(SLOTS); + for (const color of [...SERIES_COLORS.light, ...SERIES_COLORS.dark]) expect(color).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('gives each of more than eight series a color of its own', () => { + const slots: number[] = []; + for (let i = 0; i < SLOTS; i++) slots.push(freeSlot(slots)); + expect(new Set(slots.map((slot) => seriesColor(slot, false))).size).toBe(SLOTS); + }); +}); diff --git a/frontend/src/lib/palette.ts b/frontend/src/lib/palette.ts new file mode 100644 index 00000000..58f23070 --- /dev/null +++ b/frontend/src/lib/palette.ts @@ -0,0 +1,52 @@ +/** + * The colors of the series, in the order they are given, one step of each for each theme. + * + * The first `DISTINCT_COLORS` are the eight hues of the dataviz skill's palette, validated with its + * `validate_palette.js` (color-blind distance 8.4 or more between neighbours, 19 for normal vision). + * The 16 after them are not a palette but the answer to "more than 8 series without repeating a color": + * each was picked, in OKLab, as the farthest from the ones before under normal, protan and deutan + * vision (docs/FRONTEND.md has the numbers). The ninth to eleventh are 11 (light) and 10 (dark) or more + * apart from the others; from the twelfth it goes down to 5: past eight colors alone does not say + * which series is which, hovering a row of the list highlights it in the chart for that. + * The dark steps are lighter than the skill's band for the same reason: there is no room in it. + */ +export const SERIES_COLORS = { + light: [ + '#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948', + '#58aefb', '#803a5b', '#ac5291', '#3d94e0', '#495e0d', '#5bc971', '#02aec6', '#c93a63', + '#324fcf', '#6b4988', '#1e86fe', '#1987bb', '#2bc4d1', '#b86e94', '#6f51ac', '#993f43', + ], + dark: [ + '#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767', + '#65d8e3', '#fab72a', '#5ae297', '#b736a0', '#41b7c7', '#226dc9', '#70bffe', '#fa7ba7', + '#e6a05a', '#b072b5', '#c03978', '#5cacea', '#e46894', '#c74a49', '#3a70ee', '#4ac777', + ], +}; + +/** The colors that are clearly apart (the validated ones). */ +export const DISTINCT_COLORS = 8; + +export const SLOTS = SERIES_COLORS.light.length; + +export function seriesColor(slot: number, dark: boolean): string { + const colors = dark ? SERIES_COLORS.dark : SERIES_COLORS.light; + return colors[slot % colors.length]; +} + +/** + * The slot of a series that is selected: the first one nobody uses, so that a color follows its + * series for as long as it is selected. Past `SLOTS` (24) series the colors are used again: nobody + * reads a chart of 25 lines by color. + */ +export function freeSlot(used: number[]): number { + for (let slot = 0; slot < SLOTS; slot++) { + if (!used.includes(slot)) return slot; + } + return used.length % SLOTS; +} + +/** The ink of a tick on a color: dark on the light colors (the yellow), white on the others. */ +export function readableOn(hex: string): string { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? '#111827' : '#ffffff'; +} diff --git a/frontend/src/lib/seriesLabels.test.ts b/frontend/src/lib/seriesLabels.test.ts new file mode 100644 index 00000000..3131500d --- /dev/null +++ b/frontend/src/lib/seriesLabels.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { exampleSelector, sharedLabels, sortByColumns, withoutShared } from './seriesLabels'; + +describe('sharedLabels', () => { + it('keeps what every series has the same, nothing for one series', () => { + const list = [ + { org: 'a', room: 'lab' }, + { org: 'a', room: 'office' }, + ]; + expect(sharedLabels(list)).toEqual({ org: 'a' }); + expect(sharedLabels([list[0]])).toEqual({}); + expect(sharedLabels([])).toEqual({}); + }); + + it('is not shared when one series lacks the label', () => { + expect(sharedLabels([{ org: 'a' }, { org: 'a' }, {}])).toEqual({}); + }); + + it('takes the shared labels out of one series', () => { + expect(withoutShared({ org: 'a', room: 'lab' }, { org: 'a' })).toEqual({ room: 'lab' }); + }); +}); + +describe('sortByColumns', () => { + const rows = [ + { host: 'node-10', dc: 'b' }, + { host: 'node-2', dc: 'b' }, + { host: 'node-1', dc: 'a' }, + { dc: 'a' }, + ]; + const sort = (first?: string, descending = false) => + sortByColumns(rows, (r) => r, ['host', 'dc'], first, descending).map((r) => `${r.host ?? '-'}/${r.dc}`); + + it('orders by the first column, numbers as numbers, what lacks it last', () => { + expect(sort()).toEqual(['node-1/a', 'node-2/b', 'node-10/b', '-/a']); + }); + + it('orders by the column that was chosen, then by the others', () => { + expect(sort('dc')).toEqual(['node-1/a', '-/a', 'node-2/b', 'node-10/b']); + }); + + it('can be reversed, what lacks the label staying last', () => { + expect(sort('host', true)).toEqual(['node-10/b', 'node-2/b', 'node-1/a', '-/a']); + }); + + it('leaves its input alone', () => { + sort(); + expect(rows[0].host).toBe('node-10'); + }); +}); + +describe('exampleSelector', () => { + it('takes two labels of the series, the second as a start', () => { + expect(exampleSelector({ room: 'lab', floor: '1', other: 'x' }, ['room', 'floor', 'other'])).toBe( + '{room="lab", floor=~"1.*"}', + ); + }); + + it('works with one label, and with a series that has none', () => { + expect(exampleSelector({ room: 'lab' }, ['room'])).toBe('{room="lab"}'); + expect(exampleSelector({}, [])).toBe('{label="value", other=~"va.*"}'); + expect(exampleSelector(undefined, ['room'])).toBe('{label="value", other=~"va.*"}'); + }); + + it('skips a label the series does not have, and escapes what has to be', () => { + expect(exampleSelector({ b: 'say "hi"', c: '.x' }, ['a', 'b', 'c'])).toBe(String.raw`{b="say \"hi\"", c=~"\\..*"}`); + }); +}); diff --git a/frontend/src/lib/seriesLabels.ts b/frontend/src/lib/seriesLabels.ts new file mode 100644 index 00000000..4fdbe539 --- /dev/null +++ b/frontend/src/lib/seriesLabels.ts @@ -0,0 +1,67 @@ +export type Labels = Record; + +/** `node-2` before `node-10`, and `A` next to `a`. */ +export function naturalCompare(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); +} + +/** + * The labels that have the same value on every series, which is nothing for a single series (it would + * say everything it has). Said once above a list, they would only repeat in a column. + */ +export function sharedLabels(all: Labels[]): Labels { + if (all.length < 2) return {}; + const [first, ...others] = all; + return Object.fromEntries( + Object.entries(first).filter(([key, value]) => others.every((labels) => labels[key] === value)), + ); +} + +/** The labels of a series that tell it from the others of a selection. */ +export function withoutShared(labels: Labels, shared: Labels): Labels { + return Object.fromEntries(Object.entries(labels).filter(([key]) => !(key in shared))); +} + +/** + * Series ordered by the columns in turn: the one that was clicked first, then the others in their + * order, a missing label coming last. Numbers inside the values are read as numbers. + */ +export function sortByColumns( + items: T[], + labelsOf: (item: T) => Partial, + columns: string[], + first: string | undefined, + descending: boolean, +): T[] { + const order = first ? [first, ...columns.filter((c) => c !== first)] : columns; + return [...items].sort((a, b) => { + for (const [index, column] of order.entries()) { + const left = labelsOf(a)[column]; + const right = labelsOf(b)[column]; + if (left === right) continue; + if (left === undefined) return 1; + if (right === undefined) return -1; + const result = naturalCompare(left, right); + if (result !== 0) return index === 0 && descending ? -result : result; + } + return 0; + }); +} + +const quote = (value: string) => `"${value.replace(/[\\"]/g, '\\$&')}"`; + +/** + * A selector made of what the series have, to show how to write one: the first label equals its value, + * the second starts like its value (`{room="lab", floor=~"1.*"}`). + */ +export function exampleSelector(labels: Labels | undefined, columns: string[]): string { + const known = columns.filter((column) => labels?.[column] !== undefined).slice(0, 2); + if (!labels || known.length === 0) return '{label="value", other=~"va.*"}'; + const [one, two] = known; + const parts = [`${one}=${quote(labels[one])}`]; + if (two) { + const start = labels[two].slice(0, 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + parts.push(`${two}=~${quote(`${start}.*`)}`); + } + return `{${parts.join(', ')}}`; +} diff --git a/frontend/src/lib/snippets.test.ts b/frontend/src/lib/snippets.test.ts new file mode 100644 index 00000000..f276d8de --- /dev/null +++ b/frontend/src/lib/snippets.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; +import { curlSnippet, pythonSnippet } from './snippets'; +import type { SnippetInput } from './snippets'; + +const UUID_A = '11111111-1111-4111-8111-111111111111'; +const UUID_B = '22222222-2222-4222-8222-222222222222'; + +const base: SnippetInput = { + baseUrl: 'http://localhost:3000', + authenticated: false, + metric: 'cpu', + series: [{ uuid: UUID_A, name: 'cpu', labels: { host: 'a' }, type: 'float' }], + timeRange: { start: '2026-10-05T09:00:00.000Z', end: '2026-10-05T10:00:00.000Z' }, + relativeRange: null, + step: undefined, + aggregation: 'avg', +}; + +describe('pythonSnippet', () => { + it('reads a window of dates as they are, with the name of the series in a comment', () => { + const code = pythonSnippet(base); + expect(code).toContain('from sensapp import SensAppClient'); + expect(code).toContain(` "${UUID_A}", # cpu{host="a"}`); + expect(code).toContain('start = "2026-10-05T09:00:00Z"'); + expect(code).toContain('end = "2026-10-05T10:00:00Z"'); + expect(code).toContain('async with SensAppClient("http://localhost:3000") as client:'); + expect(code).toContain(' start=start,'); + expect(code).not.toContain('step='); + expect(code).not.toContain('datetime'); + expect(code).not.toContain('SENSAPP_TOKEN'); + }); + + it('writes a preset window relative to now', () => { + const code = pythonSnippet({ ...base, relativeRange: '24h' }); + expect(code).toContain('from datetime import UTC, datetime, timedelta'); + expect(code).toContain('end = datetime.now(UTC)'); + expect(code).toContain('start = end - timedelta(hours=24)'); + expect(code).toContain('start=start.isoformat(timespec="seconds"),'); + expect(code).not.toContain('2026-10-05'); + expect(pythonSnippet({ ...base, relativeRange: '7d' })).toContain('timedelta(days=7)'); + expect(pythonSnippet({ ...base, relativeRange: '15m' })).toContain('timedelta(minutes=15)'); + }); + + it('asks for the step and the aggregation of the chart', () => { + const code = pythonSnippet({ ...base, step: '5m', aggregation: 'max' }); + expect(code).toContain('step="5m",'); + expect(code).toContain('aggregation="max",'); + }); + + it('reads the token from the environment, and never has one', () => { + const code = pythonSnippet({ ...base, authenticated: true }); + expect(code).toContain('import os'); + expect(code).toContain('token=os.environ["SENSAPP_TOKEN"],'); + expect(code).toContain('sensapp generate-token'); + }); + + it('keeps the series a step cannot average apart, read as they are', () => { + const code = pythonSnippet({ + ...base, + step: '1m', + series: [ + base.series[0], + { uuid: UUID_B, name: 'door', labels: {}, type: 'boolean' }, + ], + }); + expect(code).toContain(`SERIES = [\n "${UUID_A}"`); + expect(code).toContain(`RAW_SERIES = [\n "${UUID_B}", # door\n]`); + expect(code.match(/step="1m"/g)).toHaveLength(1); + expect(code).toContain('for uuid in RAW_SERIES:'); + }); + + it('lists the series of the metric when none is selected, the metrics when there is no metric', () => { + const metric = pythonSnippet({ ...base, series: [] }); + expect(metric).toContain('client.list_series(metric="cpu")'); + expect(metric).not.toContain('get_series'); + const none = pythonSnippet({ ...base, series: [], metric: null }); + expect(none).toContain('client.list_metrics()'); + }); + + it('is not tricked by a label: quotes are escaped and a newline does not end the comment', () => { + const code = pythonSnippet({ + ...base, + baseUrl: 'http://host"; import os #', + series: [ + { uuid: UUID_A, name: 'cpu', labels: { host: 'a"\nimport os; os.system("x")\r\u2028b' }, type: 'float' }, + ], + }); + // Every line of the comment stays on its line + const line = code.split('\n').find((l) => l.includes(UUID_A)); + expect(line).toContain('import os; os.system('); + expect(line?.startsWith(' "')).toBe(true); + expect(code.split('\n').filter((l) => l.startsWith('import os'))).toHaveLength(0); + expect(code).toContain('"http://host\\"; import os #"'); + }); +}); + +describe('the script header', () => { + it('lets `uv run` install the SDK (PEP 723), and keeps the install command for the others', () => { + const code = pythonSnippet(base); + expect(code.startsWith('# /// script\n# requires-python = ">=3.14"\n# dependencies = ["sensapp"]\n')).toBe(true); + expect(code).toContain( + '# [tool.uv.sources.sensapp]\n# git = "https://github.com/SINTEF/sensapp.git"\n# subdirectory = "python/sensapp"\n# branch = "main"\n# ///', + ); + expect(code).toContain('# ///\n'); + expect(code).toContain("# uv pip install 'git+https://github.com/SINTEF/sensapp.git@main#subdirectory=python/sensapp'"); + // The block closes before the code starts + expect(code.indexOf('# ///\n', 5)).toBeLessThan(code.indexOf('import asyncio')); + }); +}); + +describe('a selector', () => { + const selected: SnippetInput = { ...base, selector: '{host="a"}', metric: 'cpu value' }; + + it('asks the server for the series that match, in Python', () => { + const code = pythonSnippet(selected); + expect(code).toContain('catalog = await client.list_series(\n metric="cpu value",\n selector=\'{host="a"}\',\n )'); + expect(code).toContain('for info in catalog.series:'); + expect(code).toContain('info.uuid,'); + expect(code).not.toContain(UUID_A); + expect(code).not.toContain('SERIES'); + // No step: nothing to say about types + expect(code).not.toContain('numeric'); + }); + + it('reads a step only for the numbers among the series, whose type is not known yet', () => { + const code = pythonSnippet({ ...selected, step: '5m', aggregation: 'max' }); + expect(code).toContain('numeric = info.sensor_type.lower() in ("integer", "float", "numeric")'); + expect(code).toContain('step="5m" if numeric else None,'); + expect(code).toContain('aggregation="max" if numeric else None,'); + }); + + it('keeps the window of the explorer', () => { + expect(pythonSnippet({ ...selected, relativeRange: '24h' })).toContain('start = end - timedelta(hours=24)'); + expect(pythonSnippet(selected)).toContain('start = "2026-10-05T09:00:00Z"'); + }); + + it('quotes the selector whatever it holds', () => { + const both = pythonSnippet({ ...selected, selector: `{host=~"a.*", note="it's"}` }); + expect(both).toContain('selector="{host=~\\"a.*\\", note=\\"it\'s\\"}",'); + const ugly = pythonSnippet({ ...selected, selector: '{a="\n1"}' }); + expect(ugly.split('\n').filter((l) => l.startsWith('1"'))).toHaveLength(0); + }); + + it('lists with curl, then reads each series with jq and a loop', () => { + const code = curlSnippet({ ...selected, authenticated: true }); + expect(code).toContain(" --data-urlencode 'metric=cpu value' \\\n --data-urlencode 'selector={host=\"a\"}' \\\n 'http://localhost:3000/series' |"); + expect(code).toContain(`jq -r '.["dcat:dataset"][]["dct:identifier"]' |`); + expect(code).toContain('while read -r uuid; do'); + // $uuid is the one thing the shell has to read + expect(code).toContain('"http://localhost:3000/series/$uuid?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z"'); + expect(code.match(/Authorization: Bearer \$SENSAPP_TOKEN/g)).toHaveLength(2); + expect(code).not.toContain(UUID_A); + }); + + it('leaves the series a step cannot average out of the curl loop, and says so', () => { + const code = curlSnippet({ ...selected, step: '5m', aggregation: 'max' }); + expect(code).toContain('select(.["sensor:type"] | ascii_downcase | IN("integer", "float", "numeric"))'); + expect(code).toContain('step=5m&aggregation=max"'); + expect(code).toContain('the numeric ones'); + }); + + it('is not tricked by the selector, or by the address, in the shell', () => { + const code = curlSnippet({ ...selected, baseUrl: 'http://h"$(rm -rf ~)`x', selector: `{a="it's"}\nrm -rf ~` }); + expect(code).toContain(`--data-urlencode 'selector={a="it'\\''s"}\nrm -rf ~'`); + expect(code).toContain('"http://h\\"\\$(rm -rf ~)\\`x/series/$uuid?'); + // The comment stays on its line: a newline in the selector is only ever inside quotes + expect(code).toContain(`# The series that match {a="it's"} rm -rf ~\n`); + }); + + it('is ignored when it is blank', () => { + expect(pythonSnippet({ ...selected, selector: ' ' })).toContain(UUID_A); + }); +}); + +describe('what the series are called', () => { + const two: SnippetInput = { + ...base, + series: [ + { uuid: UUID_A, name: 'cpu', labels: { host: 'a', org: 'o' }, type: 'float' }, + { uuid: UUID_B, name: 'cpu', labels: { host: 'b', org: 'o' }, type: 'float' }, + ], + }; + + it('says what every series has once, and what tells them apart on each', () => { + const python = pythonSnippet(two); + expect(python).toContain('# On every series: org="o"\nSERIES = ['); + expect(python).toContain(`"${UUID_A}", # cpu{host="a"}`); + const curl = curlSnippet(two); + expect(curl).toContain('# On every series: org="o"\n'); + expect(curl).toContain('# cpu{host="b"}\ncurl'); + }); + + it('says everything of a single series', () => { + const one = { ...two, series: [two.series[0]] }; + expect(pythonSnippet(one)).toContain('# cpu{host="a", org="o"}'); + expect(pythonSnippet(one)).not.toContain('On every series'); + }); +}); + +describe('curlSnippet', () => { + it('is one request for each series, with the window and the name in a comment', () => { + const code = curlSnippet({ + ...base, + series: [base.series[0], { uuid: UUID_B, name: 'mem', labels: {}, type: 'integer' }], + }); + expect(code.match(/^curl /gm)).toHaveLength(2); + expect(code).toContain('# cpu{host="a"}'); + expect(code).toContain( + `'http://localhost:3000/series/${UUID_A}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z'`, + ); + expect(code).toContain(`/series/${UUID_B}?`); + expect(code).not.toContain('Authorization'); + }); + + it('asks for the step and the aggregation, except for what cannot be averaged', () => { + const code = curlSnippet({ + ...base, + step: '5m', + aggregation: 'max', + series: [base.series[0], { uuid: UUID_B, name: 'door', labels: {}, type: 'boolean' }], + }); + expect(code).toContain(`/series/${UUID_A}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z&step=5m&aggregation=max'`); + expect(code).toContain(`/series/${UUID_B}?format=csv&start=2026-10-05T09:00:00Z&end=2026-10-05T10:00:00Z'`); + }); + + it('sends the token of the environment', () => { + const code = curlSnippet({ ...base, authenticated: true }); + expect(code).toContain('-H "Authorization: Bearer $SENSAPP_TOKEN" \\'); + expect(code).toContain('# SENSAPP_TOKEN holds a token'); + }); + + it('lists the series of the metric, or the metrics', () => { + expect(curlSnippet({ ...base, series: [] })).toContain("'http://localhost:3000/series?metric=cpu'"); + expect(curlSnippet({ ...base, series: [], metric: null })).toContain("'http://localhost:3000/metrics'"); + }); + + it('is not tricked by a quote or a newline in what it quotes', () => { + const code = curlSnippet({ + ...base, + baseUrl: "http://host'; rm -rf ~; echo '", + series: [{ uuid: UUID_A, name: 'cpu', labels: { host: 'a\nrm -rf ~' }, type: 'float' }], + }); + expect(code).toContain(`'http://host'\\''; rm -rf ~; echo '\\''/series/`); + expect(code.split('\n').filter((l) => l.startsWith('rm '))).toHaveLength(0); + }); +}); diff --git a/frontend/src/lib/snippets.ts b/frontend/src/lib/snippets.ts new file mode 100644 index 00000000..522b4cff --- /dev/null +++ b/frontend/src/lib/snippets.ts @@ -0,0 +1,290 @@ +import { isNumericType } from './chartStep'; +import type { Aggregation } from './chartStep'; +import { sharedLabels, withoutShared } from './seriesLabels'; +import type { TimeRange } from './timeRange'; + +/** What a snippet says of the explorer: what is selected, and how it is read. */ +export interface SnippetInput { + /** The origin of the SensApp server */ + baseUrl: string; + /** The server asked for a token: the snippet reads it from `SENSAPP_TOKEN`, it never contains one */ + authenticated: boolean; + metric: string | null; + /** The selector of the series list (`{host="a"}`). Set, the code asks the server for the series that match it. */ + selector?: string; + series: Array<{ uuid: string; name: string; labels: Record; type: string }>; + timeRange: TimeRange; + /** A preset such as `24h`: the window is written relative to now, as it is on screen */ + relativeRange: string | null; + /** The step of the chart, `undefined` for the raw samples */ + step: string | undefined; + aggregation: Aggregation; +} + +export const SNIPPET_LANGUAGES = ['python', 'curl'] as const; +export type SnippetLanguage = (typeof SNIPPET_LANGUAGES)[number]; + +const GIT_URL = 'https://github.com/SINTEF/sensapp.git'; +export const INSTALL_COMMENT = `uv pip install 'git+${GIT_URL}@main#subdirectory=python/sensapp'`; + +/** The block that `uv run` reads (PEP 723): the script says what it needs, and where the SDK comes from. */ +export const SCRIPT_METADATA = [ + '# /// script', + '# requires-python = ">=3.14"', + '# dependencies = ["sensapp"]', + '#', + '# [tool.uv.sources.sensapp]', + `# git = "${GIT_URL}"`, + '# subdirectory = "python/sensapp"', + '# branch = "main"', + '# ///', +]; +export const TOKEN_COMMENT = 'sensapp generate-token me --scope read'; + +/** A line of text for a comment. A newline in a label must not turn the rest into code. */ +function oneLine(text: string): string { + return text.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, ' '); +} + +function pairs(labels: Record): string { + return Object.entries(labels) + .map(([key, value]) => `${key}="${value}"`) + .join(', '); +} + +/** + * What the series are called in the comments: the name and the labels that tell a series from the others, + * as in the list of the explorer. What every series has is said once, in `shared`. + */ +function names(series: SnippetInput['series']): { label: (s: SnippetInput['series'][number]) => string; shared: string | null } { + const common = sharedLabels(series.map((s) => s.labels)); + return { + label: (s) => { + const own = pairs(withoutShared(s.labels, common)); + return oneLine(own ? `${s.name}{${own}}` : s.name); + }, + shared: Object.keys(common).length > 0 ? oneLine(`On every series: ${pairs(common)}`) : null, + }; +} + +/** A Python string literal. A JSON string is one, with its escapes. */ +function py(value: string): string { + return JSON.stringify(value); +} + +/** The same, in single quotes when that says it with fewer backslashes: `'{host="a"}'`. */ +function pyQuoted(value: string): string { + return value.includes('"') && !/['\\\p{Cc}\p{Zl}\p{Zp}]/u.test(value) ? `'${value}'` : py(value); +} + +/** What goes inside double quotes in the shell, where `$uuid` is read: the rest has to be escaped. */ +function escapeDouble(value: string): string { + return value.replace(/[\\"$`]/g, '\\$&'); +} + +/** A shell word that is one word whatever it contains. */ +function sh(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** The date the way a person writes it: `2026-10-05T09:00:00Z`, the milliseconds only when there are some. */ +function clean(iso: string): string { + return iso.replace(/\.000Z$/, 'Z'); +} + +/** `24h` as `hours=24`, the argument of a Python `timedelta`. */ +function timedelta(label: string): string | undefined { + const match = /^(\d+)([mhd])$/.exec(label); + if (!match) return undefined; + const unit = { m: 'minutes', h: 'hours', d: 'days' }[match[2] as 'm' | 'h' | 'd']; + return `${unit}=${match[1]}`; +} + +/** The series that the step can average, and the ones it cannot (booleans, strings): those are read as they are. */ +function groups(input: SnippetInput): Array<{ series: SnippetInput['series']; step?: string }> { + if (!input.step) return [{ series: input.series }]; + const aggregated = input.series.filter((s) => isNumericType(s.type)); + const raw = input.series.filter((s) => !isNumericType(s.type)); + return [ + ...(aggregated.length > 0 ? [{ series: aggregated, step: input.step }] : []), + ...(raw.length > 0 ? [{ series: raw }] : []), + ]; +} + +/** The selector to use, if the user typed one. */ +function selectorOf(input: SnippetInput): string | undefined { + return input.selector?.trim() || undefined; +} + +export function pythonSnippet(input: SnippetInput): string { + const delta = input.relativeRange ? timedelta(input.relativeRange) : undefined; + const selector = selectorOf(input); + const reads = input.series.length > 0 || selector !== undefined; + const lines: string[] = [...SCRIPT_METADATA]; + + lines.push('#', '# Run it with `uv run script.py`, or install the SDK first:', `# ${INSTALL_COMMENT}`); + if (input.authenticated) lines.push('#', '# The token comes from the environment, make one with:', `# export SENSAPP_TOKEN=$(${TOKEN_COMMENT})`); + lines.push('', 'import asyncio'); + if (input.authenticated) lines.push('import os'); + if (delta && reads) lines.push('from datetime import UTC, datetime, timedelta'); + lines.push('', 'from sensapp import SensAppClient'); + + const client = input.authenticated + ? [` async with SensAppClient(`, ` ${py(input.baseUrl)},`, ` token=os.environ["SENSAPP_TOKEN"],`, ` ) as client:`] + : [` async with SensAppClient(${py(input.baseUrl)}) as client:`]; + + if (!reads) { + lines.push('', '', 'async def main() -> None:', ...client); + if (input.metric) { + lines.push( + ` catalog = await client.list_series(metric=${py(input.metric)})`, + ' for info in catalog.series:', + ' print(info.uuid, info.name, info.labels)', + ); + } else { + lines.push( + ' catalog = await client.list_metrics()', + ' for metric in catalog.metrics:', + ' print(metric.name, metric.sensor_type, metric.series_count)', + ); + } + lines.push('', '', 'asyncio.run(main())'); + return lines.join('\n') + '\n'; + } + + // What the window is: now minus a duration, or the two dates + const window: string[] = delta + ? [' end = datetime.now(UTC)', ` start = end - timedelta(${delta})`] + : [` start = ${py(clean(input.timeRange.start))}`, ` end = ${py(clean(input.timeRange.end))}`]; + const [start, end] = delta ? ['start.isoformat(timespec="seconds")', 'end.isoformat(timespec="seconds")'] : ['start', 'end']; + const read = (indent: string, id: string, step?: string[]) => [ + `${indent}series = await client.get_series(`, + `${indent} ${id},`, + `${indent} start=${start},`, + `${indent} end=${end},`, + ...(step ?? []).map((line) => `${indent} ${line}`), + `${indent})`, + `${indent}print(series.name, series.labels)`, + `${indent}print(series.frame) # a Polars DataFrame: timestamp, value`, + ]; + + if (selector !== undefined) { + // The series are known when the code runs, not now: a step is for the numbers among them + lines.push('', '', 'async def main() -> None:', ...window, ...client); + lines.push( + ' catalog = await client.list_series(', + ...(input.metric ? [` metric=${py(input.metric)},`] : []), + ` selector=${pyQuoted(selector)},`, + ' )', + ' for info in catalog.series:', + ); + if (input.step) { + lines.push( + ' # A step averages numbers: the other types are read as they are', + ' numeric = info.sensor_type.lower() in ("integer", "float", "numeric")', + ); + } + lines.push( + ...read( + ' ', + 'info.uuid', + input.step ? [`step=${py(input.step)} if numeric else None,`, `aggregation=${py(input.aggregation)} if numeric else None,`] : undefined, + ), + '', + '', + 'asyncio.run(main())', + ); + return lines.join('\n') + '\n'; + } + + const parts = groups(input); + const mixed = parts.length > 1; + const variables = parts.map((part) => (mixed && !part.step ? 'RAW_SERIES' : 'SERIES')); + const { label, shared } = names(input.series); + + parts.forEach((part, index) => { + lines.push(''); + if (index === 0 && shared) lines.push(`# ${shared}`); + lines.push(`${variables[index]} = [`); + for (const s of part.series) lines.push(` ${py(s.uuid)}, # ${label(s)}`); + lines.push(']'); + }); + + lines.push('', '', 'async def main() -> None:', ...window, ...client); + parts.forEach((part, index) => { + lines.push( + ` for uuid in ${variables[index]}:`, + ...read(' ', 'uuid', part.step ? [`step=${py(part.step)},`, `aggregation=${py(input.aggregation)},`] : undefined), + ); + }); + lines.push('', '', 'asyncio.run(main())'); + return lines.join('\n') + '\n'; +} + +export function curlSnippet(input: SnippetInput): string { + const lines: string[] = []; + const selector = selectorOf(input); + const { label, shared } = names(input.series); + if (input.authenticated) { + lines.push('# SENSAPP_TOKEN holds a token, made with:', `# ${TOKEN_COMMENT}`, ''); + } + if (selector === undefined && shared) lines.push(`# ${shared}`, ''); + const auth = input.authenticated ? ['-H "Authorization: Bearer $SENSAPP_TOKEN"'] : []; + + /** The lines of a request: each option on its own line, the last one the address. */ + const request = (indent: string, options: string[], url: string) => [ + `${indent}curl -sS --fail-with-body \\`, + ...[...auth, ...options].map((line) => `${indent} ${line} \\`), + `${indent} ${url}`, + ]; + const readParams = (step: string | undefined) => { + const params = new URLSearchParams({ format: 'csv', start: clean(input.timeRange.start), end: clean(input.timeRange.end) }); + if (step) { + params.set('step', step); + params.set('aggregation', input.aggregation); + } + // The colons of the dates are fine in a query: leave them readable + return params.toString().replace(/%3A/g, ':'); + }; + + const blocks: string[][] = []; + if (selector !== undefined) { + // The series that match, one after the other. A step averages numbers: the others are left out of that read. + const jq = input.step + ? `.["dcat:dataset"][] | select(.["sensor:type"] | ascii_downcase | IN("integer", "float", "numeric")) | .["dct:identifier"]` + : '.["dcat:dataset"][]["dct:identifier"]'; + const list = request( + '', + ['-G', ...(input.metric ? [`--data-urlencode ${sh(`metric=${input.metric}`)}`] : []), `--data-urlencode ${sh(`selector=${selector}`)}`], + `${sh(`${input.baseUrl}/series`)} |`, + ); + const each = request(' ', [], `"${escapeDouble(input.baseUrl)}/series/$uuid?${readParams(input.step)}"`); + blocks.push([ + `# The series that match ${oneLine(selector)}${input.step ? ' (the numeric ones: a step averages numbers)' : ''}`, + ...list, + ` jq -r ${sh(jq)} |`, + ' while read -r uuid; do', + ...each.map((line) => ` ${line}`), + ' done', + ]); + } else if (input.series.length === 0) { + blocks.push( + request( + '', + [], + sh(input.metric ? `${input.baseUrl}/series?${new URLSearchParams({ metric: input.metric })}` : `${input.baseUrl}/metrics`), + ), + ); + } else { + for (const part of groups(input)) { + for (const s of part.series) { + blocks.push([`# ${label(s)}`, ...request('', [], sh(`${input.baseUrl}/series/${encodeURIComponent(s.uuid)}?${readParams(part.step)}`))]); + } + } + } + return lines.concat(blocks.map((block) => block.join('\n')).join('\n\n')).join('\n') + '\n'; +} + +export function snippetFor(language: SnippetLanguage, input: SnippetInput): string { + return language === 'python' ? pythonSnippet(input) : curlSnippet(input); +} diff --git a/frontend/src/lib/timeRange.test.ts b/frontend/src/lib/timeRange.test.ts new file mode 100644 index 00000000..695d0d93 --- /dev/null +++ b/frontend/src/lib/timeRange.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { brushedRange, nextPreset, panned, rangeFor, zoomedOut } from './timeRange'; + +const NOW = new Date('2026-10-05T12:00:00.000Z'); +const window = (start: string, end: string) => ({ start: `2026-10-05T${start}:00.000Z`, end: `2026-10-05T${end}:00.000Z` }); + +describe('rangeFor', () => { + it('ends now', () => { + expect(rangeFor('1h', NOW)).toEqual(window('11:00', '12:00')); + expect(rangeFor('nope', NOW)).toBeUndefined(); + }); +}); + +describe('brushedRange', () => { + it('is in whole seconds, outwards, whichever way the drag went', () => { + const expected = { start: '2026-10-05T10:00:01.000Z', end: '2026-10-05T10:00:05.000Z' }; + expect(brushedRange(Date.parse('2026-10-05T10:00:01.900Z'), Date.parse('2026-10-05T10:00:04.100Z'))).toEqual(expected); + expect(brushedRange(Date.parse('2026-10-05T10:00:04.100Z'), Date.parse('2026-10-05T10:00:01.900Z'))).toEqual(expected); + }); + + it('is nothing for a click, or for what is not a time', () => { + const t = Date.parse('2026-10-05T10:00:01.000Z'); + expect(brushedRange(t, t)).toBeUndefined(); + expect(brushedRange(t, t + 400)).toBeUndefined(); + expect(brushedRange(NaN, t)).toBeUndefined(); + }); +}); + +describe('panned', () => { + it('moves by half a window', () => { + expect(panned(window('10:00', '11:00'), -1, NOW)).toEqual(window('09:30', '10:30')); + expect(panned(window('10:00', '11:00'), 1, NOW)).toEqual(window('10:30', '11:30')); + }); + + it('does not go past now', () => { + expect(panned(window('10:30', '11:50'), 1, NOW)).toEqual(window('10:40', '12:00')); + expect(panned(window('11:00', '12:00'), 1, NOW)).toEqual(window('11:00', '12:00')); + }); +}); + +describe('zoomedOut', () => { + it('is twice the window around its middle', () => { + expect(zoomedOut(window('10:00', '11:00'), NOW)).toEqual(window('09:30', '11:30')); + }); + + it('keeps the end at now at the latest, and takes the rest from before', () => { + expect(zoomedOut(window('11:00', '12:00'), NOW)).toEqual(window('10:00', '12:00')); + }); +}); + +describe('nextPreset', () => { + it('is the next one, and the widest stays', () => { + expect(nextPreset('1h')).toBe('6h'); + expect(nextPreset('30d')).toBe('30d'); + }); +}); diff --git a/frontend/src/lib/timeRange.ts b/frontend/src/lib/timeRange.ts new file mode 100644 index 00000000..2be7be30 --- /dev/null +++ b/frontend/src/lib/timeRange.ts @@ -0,0 +1,62 @@ +/** The ranges of the preset buttons, which are relative to now. */ +export const PRESETS = [ + { label: '15m', minutes: 15 }, + { label: '1h', minutes: 60 }, + { label: '6h', minutes: 360 }, + { label: '24h', minutes: 1440 }, + { label: '7d', minutes: 10080 }, + { label: '30d', minutes: 43200 }, +]; + +export const DEFAULT_RANGE = '1h'; + +/** The range of a preset ending now, or `undefined` for a label that is none. */ +export function rangeFor(label: string, now = new Date()): { start: string; end: string } | undefined { + const preset = PRESETS.find((p) => p.label === label); + if (!preset) return undefined; + return { + start: new Date(now.getTime() - preset.minutes * 60_000).toISOString(), + end: now.toISOString(), + }; +} + +export interface TimeRange { + start: string; + end: string; +} + +/** The smallest window a brush can ask for: a second, the finest step there is. */ +export const MIN_WINDOW_MS = 1000; + +/** The window of a brush on the chart, in whole seconds. `undefined` when it is too narrow to mean something. */ +export function brushedRange(fromMs: number, toMs: number): TimeRange | undefined { + // A drag of less than a second is a slip of the hand, not a window + if (!(Math.abs(toMs - fromMs) >= MIN_WINDOW_MS)) return undefined; + const start = Math.floor(Math.min(fromMs, toMs) / 1000) * 1000; + const end = Math.ceil(Math.max(fromMs, toMs) / 1000) * 1000; + return { start: new Date(start).toISOString(), end: new Date(end).toISOString() }; +} + +/** The window moved by half its width, earlier (`-1`) or later (`1`). It never goes past now. */ +export function panned(range: TimeRange, direction: -1 | 1, now = new Date()): TimeRange { + const start = Date.parse(range.start); + const end = Date.parse(range.end); + let shift = direction * ((end - start) / 2); + if (end + shift > now.getTime()) shift = Math.max(0, now.getTime() - end); + return { start: new Date(start + shift).toISOString(), end: new Date(end + shift).toISOString() }; +} + +/** Twice the window around its middle. The end stays at now at the latest, the window gets longer on the other side. */ +export function zoomedOut(range: TimeRange, now = new Date()): TimeRange { + const start = Date.parse(range.start); + const end = Date.parse(range.end); + const width = end - start; + const newEnd = Math.min(end + width / 2, now.getTime()); + return { start: new Date(newEnd - 2 * width).toISOString(), end: new Date(newEnd).toISOString() }; +} + +/** The preset after this one, or the same one for the widest. */ +export function nextPreset(label: string): string { + const index = PRESETS.findIndex((p) => p.label === label); + return PRESETS[Math.min(index + 1, PRESETS.length - 1)].label; +} diff --git a/frontend/src/lib/tokenFromAddress.test.ts b/frontend/src/lib/tokenFromAddress.test.ts new file mode 100644 index 00000000..5f83d3e5 --- /dev/null +++ b/frontend/src/lib/tokenFromAddress.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '../stores/useAuthStore'; +import { pickUpTokenFromAddress } from './tokenFromAddress'; + +function openAddress(address: string) { + window.history.replaceState(null, '', address); +} + +describe('pickUpTokenFromAddress', () => { + beforeEach(() => { + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + openAddress('/'); + }); + + it('signs in with the token of the fragment and takes it out of the address', () => { + openAddress('/ui/?series=a#token=abc.def.ghi'); + pickUpTokenFromAddress(); + expect(useAuthStore.getState().token).toBe('abc.def.ghi'); + expect(window.location.hash).toBe(''); + // The rest of the address stays + expect(window.location.pathname + window.location.search).toBe('/ui/?series=a'); + }); + + it('keeps the other parts of the fragment', () => { + openAddress('/ui/#token=abc&tab=1'); + pickUpTokenFromAddress(); + expect(useAuthStore.getState().token).toBe('abc'); + expect(window.location.hash).toBe('#tab=1'); + }); + + it('does nothing without a token in the address', () => { + openAddress('/ui/#section'); + pickUpTokenFromAddress(); + expect(useAuthStore.getState().token).toBeNull(); + expect(window.location.hash).toBe('#section'); + }); + + it('forgets an empty token without signing in', () => { + openAddress('/ui/#token='); + pickUpTokenFromAddress(); + expect(useAuthStore.getState().token).toBeNull(); + expect(window.location.hash).toBe(''); + }); +}); diff --git a/frontend/src/lib/tokenFromAddress.ts b/frontend/src/lib/tokenFromAddress.ts new file mode 100644 index 00000000..20504116 --- /dev/null +++ b/frontend/src/lib/tokenFromAddress.ts @@ -0,0 +1,24 @@ +import { useAuthStore } from '../stores/useAuthStore'; + +/** + * A local SensApp prints a link with its token in the address fragment (`/ui/#token=…`). The + * fragment is never sent to the server nor logged, so the token does not leave the browser: sign + * in with it, and take it out of the address so that it is not kept in the history, bookmarked or + * shared by mistake. + */ +export function pickUpTokenFromAddress(): void { + const params = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const token = params.get('token'); + if (token === null) return; + + if (token.trim() !== '') { + useAuthStore.getState().signIn(token); + } + params.delete('token'); + const rest = params.toString(); + window.history.replaceState( + window.history.state, + '', + window.location.pathname + window.location.search + (rest ? `#${rest}` : ''), + ); +} diff --git a/frontend/src/lib/urlState.test.ts b/frontend/src/lib/urlState.test.ts new file mode 100644 index 00000000..3449c57c --- /dev/null +++ b/frontend/src/lib/urlState.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { fromSearchParams, toSearchParams } from './urlState'; +import type { UrlState } from './urlState'; + +const RANGE = { start: '2026-10-04T10:00:00.000Z', end: '2026-10-04T11:00:00.000Z' }; +const plain: UrlState = { + metric: null, + series: [], + relativeRange: '1h', + timeRange: RANGE, + step: 'auto', + aggregation: 'avg', + chartStyle: 'line', + logScale: false, +}; + +const parse = (query: string) => fromSearchParams(new URLSearchParams(query)); + +describe('toSearchParams', () => { + it('leaves the plain explorer with a plain address', () => { + expect(toSearchParams(plain).toString()).toBe(''); + }); + + it('writes what was chosen', () => { + const params = toSearchParams({ + ...plain, + metric: 'temperature value', + series: ['a-1', 'b-2'], + relativeRange: '24h', + step: '5m', + aggregation: 'max', + chartStyle: 'stacked', + logScale: true, + }); + expect(params.get('metric')).toBe('temperature value'); + expect(params.getAll('series')).toEqual(['a-1', 'b-2']); + expect(params.get('range')).toBe('24h'); + expect(params.get('step')).toBe('5m'); + expect(params.get('agg')).toBe('max'); + expect(params.get('style')).toBe('stacked'); + expect(params.get('log')).toBe('1'); + expect(params.has('from')).toBe(false); + }); + + it('writes the dates once they were typed', () => { + const params = toSearchParams({ ...plain, relativeRange: null }); + expect(params.get('from')).toBe(RANGE.start); + expect(params.get('to')).toBe(RANGE.end); + expect(params.has('range')).toBe(false); + }); +}); + +describe('fromSearchParams', () => { + it('reads back what was written', () => { + const state: UrlState = { + metric: 'cpu', + series: ['a', 'b'], + relativeRange: null, + timeRange: RANGE, + step: 'raw', + aggregation: 'last', + chartStyle: 'bars', + logScale: true, + }; + expect(fromSearchParams(toSearchParams(state))).toEqual(state); + }); + + it('reads a preset', () => { + expect(parse('range=7d')).toMatchObject({ relativeRange: '7d' }); + }); + + it('ignores what makes no sense', () => { + const state = parse('range=2y&step=7x&agg=median&style=pie&log=yes&from=nope&to=2026-10-04T11:00:00Z'); + expect(state).toEqual({ series: [] }); + }); + + it('ignores dates in the wrong order', () => { + expect(parse('from=2026-10-04T11:00:00Z&to=2026-10-04T10:00:00Z')).toEqual({ series: [] }); + }); + + it('asks for a series once', () => { + expect(parse('series=a&series=a&series=b&series=').series).toEqual(['a', 'b']); + }); +}); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts new file mode 100644 index 00000000..ee2fdb46 --- /dev/null +++ b/frontend/src/lib/urlState.ts @@ -0,0 +1,71 @@ +import { AGGREGATIONS, FIXED_STEPS } from './chartStep'; +import type { Aggregation } from './chartStep'; +import { CHART_STYLES } from './chartOption'; +import type { ChartStyle } from './chartOption'; +import { DEFAULT_RANGE, PRESETS } from './timeRange'; + +/** What the address says, and what the explorer shows from it. */ +export interface UrlState { + metric: string | null; + series: string[]; + /** A preset such as `24h`, or `null` when the range is `timeRange` */ + relativeRange: string | null; + timeRange: { start: string; end: string }; + step: string; + aggregation: Aggregation; + chartStyle: ChartStyle; + logScale: boolean; +} + +/** The address of a state. What is by default is left out: the plain address is the plain explorer. */ +export function toSearchParams(state: UrlState): URLSearchParams { + const params = new URLSearchParams(); + if (state.metric) params.set('metric', state.metric); + for (const uuid of state.series) params.append('series', uuid); + if (state.relativeRange === null) { + params.set('from', state.timeRange.start); + params.set('to', state.timeRange.end); + } else if (state.relativeRange !== DEFAULT_RANGE) { + params.set('range', state.relativeRange); + } + if (state.step !== 'auto') params.set('step', state.step); + if (state.aggregation !== 'avg') params.set('agg', state.aggregation); + if (state.chartStyle !== 'line') params.set('style', state.chartStyle); + if (state.logScale) params.set('log', '1'); + return params; +} + +function isDate(value: string | null): value is string { + return value !== null && !Number.isNaN(Date.parse(value)); +} + +/** The state of an address. What is wrong or unknown is ignored: an address may be old or typed. */ +export function fromSearchParams(params: URLSearchParams): Partial { + const state: Partial = {}; + + const metric = params.get('metric'); + if (metric) state.metric = metric; + state.series = [...new Set(params.getAll('series').filter(Boolean))]; + + const from = params.get('from'); + const to = params.get('to'); + const range = params.get('range'); + if (isDate(from) && isDate(to) && Date.parse(from) < Date.parse(to)) { + state.relativeRange = null; + state.timeRange = { start: new Date(from).toISOString(), end: new Date(to).toISOString() }; + } else if (range && PRESETS.some((p) => p.label === range)) { + state.relativeRange = range; + } + + const step = params.get('step'); + if (step === 'raw' || (step && FIXED_STEPS.includes(step))) state.step = step; + const aggregation = params.get('agg'); + if (aggregation && (AGGREGATIONS as readonly string[]).includes(aggregation)) { + state.aggregation = aggregation as Aggregation; + } + const style = params.get('style'); + if (style && (CHART_STYLES as string[]).includes(style)) state.chartStyle = style as ChartStyle; + if (params.get('log') === '1') state.logScale = true; + + return state; +} diff --git a/frontend/src/lib/usePrefersDark.test.ts b/frontend/src/lib/usePrefersDark.test.ts new file mode 100644 index 00000000..d85959a0 --- /dev/null +++ b/frontend/src/lib/usePrefersDark.test.ts @@ -0,0 +1,49 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { usePrefersDark } from './usePrefersDark'; + +function mockScheme(initiallyDark: boolean) { + let dark = initiallyDark; + const listeners = new Set<() => void>(); + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return dark; + }, + addEventListener: (_: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_: string, listener: () => void) => listeners.delete(listener), + })); + return { + set(value: boolean) { + dark = value; + listeners.forEach((listener) => listener()); + }, + listeners, + }; +} + +describe('usePrefersDark', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reads the scheme of the OS', () => { + mockScheme(true); + expect(renderHook(() => usePrefersDark()).result.current).toBe(true); + mockScheme(false); + expect(renderHook(() => usePrefersDark()).result.current).toBe(false); + }); + + it('follows it when it changes, and stops listening on unmount', () => { + const scheme = mockScheme(false); + const { result, unmount } = renderHook(() => usePrefersDark()); + act(() => scheme.set(true)); + expect(result.current).toBe(true); + act(() => scheme.set(false)); + expect(result.current).toBe(false); + unmount(); + expect(scheme.listeners.size).toBe(0); + }); + + it('is light where the browser cannot tell', () => { + vi.stubGlobal('matchMedia', undefined); + expect(renderHook(() => usePrefersDark()).result.current).toBe(false); + }); +}); diff --git a/frontend/src/lib/usePrefersDark.ts b/frontend/src/lib/usePrefersDark.ts new file mode 100644 index 00000000..4ed8ad60 --- /dev/null +++ b/frontend/src/lib/usePrefersDark.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from 'react'; + +const QUERY = '(prefers-color-scheme: dark)'; + +function subscribe(onChange: () => void) { + const media = window.matchMedia?.(QUERY); + media?.addEventListener('change', onChange); + return () => media?.removeEventListener('change', onChange); +} + +/** Whether the OS asks for a dark theme, following its changes. daisyUI follows it for the page. */ +export function usePrefersDark(): boolean { + return useSyncExternalStore( + subscribe, + () => window.matchMedia?.(QUERY).matches ?? false, + () => false, + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b9af088b..f9c16893 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,27 +1,25 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' import { configureApiClient } from './api/clientConfig' +import { createQueryClient } from './api/queryClient' +import { pickUpTokenFromAddress } from './lib/tokenFromAddress' // Configure API client configureApiClient() -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, -}) +// A link printed by a local SensApp carries a token in its fragment +pickUpTokenFromAddress() + +const queryClient = createQueryClient() createRoot(document.getElementById('root')!).render( - + diff --git a/frontend/src/pages/CredentialsPage.test.tsx b/frontend/src/pages/CredentialsPage.test.tsx new file mode 100644 index 00000000..64a0c059 --- /dev/null +++ b/frontend/src/pages/CredentialsPage.test.tsx @@ -0,0 +1,292 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App'; +import { createQueryClient } from '../api/queryClient'; +import { useAuthStore } from '../stores/useAuthStore'; +import { fakeToken } from '../test/fakeToken'; + +const mockListMetrics = vi.fn(); +const mockCreateToken = vi.fn(); +vi.mock('../client', () => ({ + listMetrics: (...args: unknown[]) => mockListMetrics(...args), + listSeries: () => Promise.resolve({ data: { 'dcat:dataset': [] } }), + getSeriesData: () => Promise.resolve({ data: [] }), + createToken: (...args: unknown[]) => mockCreateToken(...args), +})); + +const ADMIN = fakeToken({ sub: 'root', scope: 'admin', exp: 4_102_444_800 }); +const READ_ADMIN = fakeToken({ sub: 'me', scope: 'read admin', exp: 4_102_444_800 }); +const READER = fakeToken({ sub: 'reader', scope: 'read', exp: 4_102_444_800 }); +// The token the local run prints has every scope +const LOCAL = fakeToken({ sub: 'local-dev', scope: 'read write delete admin', exp: 4_102_444_800 }); + +const catalog = { + 'dcat:dataset': [ + { '@id': 'a', 'dct:title': 'temperature' }, + { '@id': 'b', 'dct:title': 'humidity' }, + ], +}; + +const created = { + token: 'made.by.sensapp', + subject: 'edge-7', + scope: ['read', 'write'], + sensors: null, + expires_at: 4_102_444_800, + jti: '6f9c2f5e-0000-4000-8000-000000000000', +}; + +function renderPage() { + const queryClient = createQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } }); + return render( + + + + + , + ); +} + +const command = () => screen.getByLabelText('Command that makes the token').textContent ?? ''; + +beforeEach(() => { + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + mockListMetrics.mockReset(); + mockCreateToken.mockReset(); + mockListMetrics.mockResolvedValue({ data: catalog }); +}); + +describe('CredentialsPage', () => { + it('is a tab of the header', async () => { + renderPage(); + expect(await screen.findByRole('link', { name: 'Credentials' })).toHaveAttribute('aria-current', 'page'); + }); + + it('says that nothing needs a token when the server is open', async () => { + renderPage(); + expect(await screen.findByText('Authentication is disabled')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Name' })).toBeNull(); + }); + + it('asks for the choices in the order of their weight: the validity before the scopes', async () => { + useAuthStore.setState({ token: ADMIN }); + renderPage(); + await screen.findByRole('textbox', { name: 'Name' }); + const titles = screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent); + expect(titles).toEqual(['Name', 'Valid for', 'What it may do', 'Only these sensors', 'Make it here', 'Command line']); + }); + + it('shows the form and the command, and how to get an admin token, to a server that wants a token', async () => { + mockListMetrics.mockResolvedValue({ error: { error: 'Missing or invalid Authorization header' }, response: { status: 401 } }); + renderPage(); + expect(await screen.findByRole('textbox', { name: 'Name' })).toBeInTheDocument(); + expect(command()).toContain('sensapp generate-token NAME --scope read,write --duration 86400'); + expect(screen.getByLabelText('Command that makes an admin token')).toHaveTextContent('sensapp generate-token me --scope read,admin'); + expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); + // The sign-in dialog opened with the answer of the server, and the command of an admin token + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('sensapp generate-token me --scope read,admin'); + // Not the command of the read token that the other pages suggest + expect(dialog).not.toHaveTextContent('generate-token ui --scope read'); + }); + + it('says that the token in use is not an admin one, and leaves the command', async () => { + useAuthStore.setState({ token: READER }); + renderPage(); + expect(await screen.findByText(/does not have it/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Make the token' })).toBeNull(); + expect(command()).toContain('sensapp generate-token NAME'); + }); + + it('does not read the catalog with an admin token, which cannot read: the refusal would ask for a token again', async () => { + useAuthStore.setState({ token: ADMIN }); + renderPage(); + expect(await screen.findByRole('button', { name: 'Make the token' })).toBeInTheDocument(); + expect(mockListMetrics).not.toHaveBeenCalled(); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); + + it('reads the sensors with the token the page suggests, which has read and admin: nothing asks for another token', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: READ_ADMIN }); + mockCreateToken.mockResolvedValue({ data: created }); + renderPage(); + + // The form is the admin's, and the sensors of the server are offered + await user.click(await screen.findByRole('button', { name: 'temperature' })); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeInTheDocument(); + expect(mockListMetrics).toHaveBeenCalled(); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); + + it('writes the command as the form is filled, quoted, with a --sensor for each name', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), "bob's phone"); + await user.click(screen.getByRole('radio', { name: '1 hour' })); + await user.click(screen.getByRole('checkbox', { name: /^delete/ })); + const sensor = screen.getByRole('textbox', { name: 'Sensor name' }); + await user.type(sensor, 'cpu,usage{Enter}'); + await user.type(sensor, 'temperature{Enter}'); + + expect(command()).toBe( + "sensapp generate-token 'bob'\\''s phone' --scope read,write,delete --duration 3600 \\\n --sensor 'cpu,usage' \\\n --sensor temperature\n", + ); + }); + + it('closes the token when a choice of the form changes, so it is not taken for the new one', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + mockCreateToken.mockResolvedValue({ data: created }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'edge-7'); + for (const change of [ + () => user.type(screen.getByRole('textbox', { name: 'Name' }), '8'), + () => user.click(screen.getByRole('radio', { name: '1 hour' })), + () => user.click(screen.getByRole('checkbox', { name: /^delete/ })), + () => user.type(screen.getByRole('textbox', { name: 'Sensor name' }), 'cpu{Enter}'), + ]) { + await user.click(screen.getByRole('button', { name: 'Make the token' })); + expect(await screen.findByLabelText('Token')).toBeInTheDocument(); + await change(); + await waitFor(() => expect(screen.queryByLabelText('Token')).toBeNull()); + } + }); + + it('fills the name with a random one, to click again for another', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + const name = await screen.findByRole('textbox', { name: 'Name' }); + await user.click(screen.getByRole('button', { name: 'Random name' })); + expect((name as HTMLInputElement).value).toMatch(/^[a-z]+-[a-z]+-[a-z]+-\d{1,2}$/); + expect(command()).toContain(`generate-token ${(name as HTMLInputElement).value} `); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeEnabled(); + }); + + it('makes an admin token with the command only', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'root'); + await user.click(screen.getByRole('checkbox', { name: /^admin/ })); + expect(command()).toContain('--scope read,write,admin'); + expect(screen.getByText(/only made with the command line/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Make the token' })).toBeDisabled(); + expect(mockCreateToken).not.toHaveBeenCalled(); + }); + + it('makes a token with what the form says, shows it once, then lets it go with its cross', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + mockCreateToken.mockResolvedValue({ data: created }); + renderPage(); + + const submit = await screen.findByRole('button', { name: 'Make the token' }); + // A name is needed, and at least one scope + expect(submit).toBeDisabled(); + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' edge-7 '); + expect(submit).toBeEnabled(); + await user.click(screen.getByRole('radio', { name: '1 hour' })); + const sensor = screen.getByRole('textbox', { name: 'Sensor name' }); + await user.type(sensor, 'temperature{Enter}'); + await user.type(sensor, 'cpu,usage{Enter}'); + await user.type(sensor, 'temperature{Enter}'); + await user.click(submit); + + // A comma is part of a name, and the repeated name is kept once + expect(mockCreateToken).toHaveBeenCalledWith({ + body: { subject: 'edge-7', scope: ['read', 'write'], sensors: ['temperature', 'cpu,usage'], duration_seconds: 3600 }, + }); + + expect(await screen.findByText(/does not keep tokens/)).toBeInTheDocument(); + // The token is under the button, before the command line + expect(submit.compareDocumentPosition(screen.getByLabelText('Token')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByLabelText('Token')).toHaveTextContent('made.by.sensapp'); + expect(screen.getByLabelText('Export of the token')).toHaveTextContent('export SENSAPP_TOKEN=made.by.sensapp'); + expect(screen.getByText(created.jti)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Copy Token' })); + expect(await screen.findByText('Copied to the clipboard')).toBeInTheDocument(); + expect(await navigator.clipboard.readText()).toBe('made.by.sensapp'); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByLabelText('Token')).toBeNull()); + // The form is still there, with what was typed + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(' edge-7 '); + }); + + it('lets a sensor go, and does not add a blank one', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + mockCreateToken.mockResolvedValue({ data: created }); + renderPage(); + + const sensor = await screen.findByRole('textbox', { name: 'Sensor name' }); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + await user.type(sensor, ' {Enter}'); + expect(screen.queryByRole('list', { name: 'Allowed sensors' })).toBeNull(); + + await user.type(sensor, 'temperature{Enter}humidity{Enter}'); + const chips = screen.getByRole('list', { name: 'Allowed sensors' }); + expect(within(chips).getAllByRole('listitem')).toHaveLength(2); + await user.click(screen.getByRole('button', { name: 'Remove temperature' })); + expect(within(screen.getByRole('list', { name: 'Allowed sensors' })).getAllByRole('listitem')).toHaveLength(1); + expect(command()).toContain('--sensor humidity'); + expect(command()).not.toContain('temperature'); + }); + + it('offers the sensors of the server to a token that can read, to click in', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: LOCAL }); + renderPage(); + + await user.click(await screen.findByRole('button', { name: 'temperature' })); + await user.click(screen.getByRole('button', { name: 'humidity' })); + expect(within(screen.getByRole('list', { name: 'Allowed sensors' })).getAllByRole('listitem')).toHaveLength(2); + // What is chosen is not offered again + expect(screen.queryByRole('button', { name: 'temperature' })).toBeNull(); + }); + + it('shows what the server refused in the form, to fix it', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + mockCreateToken.mockResolvedValue({ + error: { error: 'The duration cannot be longer than 3600 seconds' }, + response: { status: 400 }, + }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'edge-7'); + await user.click(screen.getByRole('button', { name: 'Make the token' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('The duration cannot be longer than 3600 seconds'); + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('edge-7'); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); + + it('asks again for a token when the server says the one in use is not an admin one', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: ADMIN }); + mockCreateToken.mockResolvedValue({ error: { error: 'admin access required' }, response: { status: 403 } }); + renderPage(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'edge-7'); + await user.click(screen.getByRole('button', { name: 'Make the token' })); + + await waitFor(() => expect(useAuthStore.getState().dialogOpen).toBe(true)); + expect(useAuthStore.getState().message).toBe('admin access required'); + // The refusal is for the dialog, not repeated in the form + expect(within(screen.getByRole('main')).queryByRole('alert')).toBeNull(); + }); +}); diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx new file mode 100644 index 00000000..4a2880f9 --- /dev/null +++ b/frontend/src/pages/CredentialsPage.tsx @@ -0,0 +1,335 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent, ReactNode } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { createToken } from '../client'; +import type { CreatedToken } from '../client'; +import { ApiError, unwrap } from '../api/clientConfig'; +import { CodeBlock } from '../components/CodeBlock'; +import { Loading } from '../components/Feedback'; +import { useMetrics } from '../hooks/useMetrics'; +import { ADMIN_TOKEN_COMMAND, addSensor, tokenCommand, tokenScopes } from '../lib/credentials'; +import { randomName } from '../lib/names'; +import { describeToken, useAuthStore } from '../stores/useAuthStore'; + +const DAY = 24 * 3600; +const DURATIONS = [ + { label: '1 hour', seconds: 3600 }, + { label: '1 day', seconds: DAY }, + { label: '30 days', seconds: 30 * DAY }, + { label: '1 year', seconds: 365 * DAY }, +] as const; + +const SCOPES = [ + { id: 'read', help: 'Read the series and the samples' }, + { id: 'write', help: 'Send samples' }, + { id: 'delete', help: 'Delete series and samples, and run the maintenance' }, + { id: 'admin', help: 'Make tokens, nothing else. Only with the command line' }, +] as const; + +/** How many sensor names are offered to click, not to fill a page of a large server. */ +const SUGGESTED_SENSORS = 40; + +/** A part of the page, as on Load Data: a title and a sentence on the left, the content on the right. */ +function Row({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+ ); +} + +const code = (text: string) => {text}; + +function SensorsField({ + sensors, + onChange, + offered, +}: { + sensors: string[]; + onChange: (sensors: string[]) => void; + /** Names of the sensors of the server, to click in */ + offered: string[]; +}) { + const [draft, setDraft] = useState(''); + + function add() { + onChange(addSensor(sensors, draft)); + setDraft(''); + } + + const suggestions = offered.filter((name) => !sensors.includes(name)); + return ( +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => { + // Enter adds the name, it does not submit the form + if (event.key === 'Enter') { + event.preventDefault(); + add(); + } + }} + /> + +
+ + {sensors.length > 0 && ( +
    + {sensors.map((name) => ( +
  • + {name} + +
  • + ))} +
+ )} + + {suggestions.length > 0 && ( +
+ {suggestions.slice(0, SUGGESTED_SENSORS).map((name) => ( + + ))} + {suggestions.length > SUGGESTED_SENSORS && ( + and {suggestions.length - SUGGESTED_SENSORS} more + )} +
+ )} +
+ ); +} + +/** The token that was just made, under the button that made it. */ +function CreatedTokenResult({ created, onDone }: { created: CreatedToken; onDone: () => void }) { + return ( +
+ +
+ +
+ +

SensApp does not keep tokens, copy it now.

+ +
+
For
+
{created.subject}
+
Scope
+
{created.scope.join(', ')}
+
Sensors
+
{created.sensors?.length ? created.sensors.join(' · ') : 'all'}
+
Expires
+
{new Date(created.expires_at * 1000).toLocaleString()}
+
Id
+
{created.jti}
+
+
+ ); +} + +/** Where a token is made when this page cannot: the command line, with the secret. */ +function AuthenticationDisabled() { + return ( + +

+ To require tokens, give SensApp a secret with {code('SENSAPP_JWT_SECRET')} ({code('sensapp generate-secret')} makes one) and remove{' '} + {code('SENSAPP_AUTH_DISABLED')}. +

+
+ ); +} + +/** What the page offers: the form, the command that makes the same token, and the button when the token in use is an admin's. */ +function TokenMaker({ token, canRead, isAdmin }: { token: string | null; canRead: boolean; isAdmin: boolean }) { + const metrics = useMetrics(undefined, { enabled: canRead }); + const openDialog = useAuthStore((state) => state.openDialog); + + const [subject, setSubject] = useState(''); + const [durationSeconds, setDuration] = useState(DAY); + const [scope, setScope] = useState(['read', 'write']); + const [sensors, setSensors] = useState([]); + + const mutation = useMutation({ + mutationFn: async (body: Parameters[0]['body']) => unwrap(await createToken({ body })), + // The token is not kept in the cache of the mutations once the page is left + gcTime: 0, + }); + + // A token made with other choices than the form now has would be confusing: it goes + const { reset } = mutation; + useEffect(() => reset(), [subject, durationSeconds, scope, sensors, reset]); + + const wish = { subject, scope, sensors, durationSeconds }; + const names = (metrics.data?.['dcat:dataset'] ?? []).map((dataset) => dataset['dct:title']); + const wantsAdmin = scope.includes('admin'); + const ready = subject.trim() !== '' && scope.length > 0; + + function toggle(id: string) { + setScope((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id])); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!ready || wantsAdmin) return; + mutation.mutate({ subject: subject.trim(), scope, sensors: sensors.length > 0 ? sensors : null, duration_seconds: durationSeconds }); + } + + const refused = mutation.error instanceof ApiError && mutation.error.isAuthError; + + return ( +
+ +
+ setSubject(event.target.value)} + /> + +
+
+ + +
+ {DURATIONS.map((item) => ( + setDuration(item.seconds)} + /> + ))} +
+
+ + +
+ Scopes + {SCOPES.map((item) => ( + + ))} +
+
+ + + + + + Needs a token with the {code('admin')} scope, which gives nothing else: the command has {code('read')} too, to browse the data with it. + ) + } + > + {isAdmin ? ( + <> + {mutation.isError && !refused && ( +
+ {mutation.error instanceof Error ? mutation.error.message : 'The token was not made'} +
+ )} + {wantsAdmin &&

An admin token is only made with the command line.

} +
+ +
+ {mutation.isSuccess && mutation.reset()} />} + + ) : ( + <> +

+ {token !== null ? 'The token in use does not have it. ' : ''}Make an admin token with the command line, it lasts an hour: +

+ +
+ +
+ + )} +
+ + + The same token, made where SensApp runs: it needs the secret. It is the only way to make an {code('admin')} token. In a container, put{' '} + {code('docker exec ')} in front, on Kubernetes {code('kubectl exec deploy/ --')}. + + } + > + + +
+ ); +} + +/** Where an admin makes the tokens of the clients, and how anyone makes the admin token. */ +export function CredentialsPage() { + const token = useAuthStore((state) => state.token); + // Without a token, the catalog tells whether the server asks for one. With one, it is not + // asked here: an admin token cannot read, and the refusal would ask for a token again. + const probe = useMetrics(undefined, { enabled: token === null }); + const scopes = token === null ? [] : tokenScopes(describeToken(token)); + + let content: ReactNode; + if (token === null && probe.isPending) { + content = ; + } else if (token === null && probe.isSuccess) { + content = ; + } else { + content = ; + } + + return ( +
+ {content} +
+ ); +} + +export default CredentialsPage; diff --git a/frontend/src/pages/ExplorerPage.tsx b/frontend/src/pages/ExplorerPage.tsx index 9142b151..e22b6dc7 100644 --- a/frontend/src/pages/ExplorerPage.tsx +++ b/frontend/src/pages/ExplorerPage.tsx @@ -1,29 +1,27 @@ +import { ChartOptions } from '../components/ChartOptions'; import { MetricsTable } from '../components/MetricsTable'; import { SeriesTable } from '../components/SeriesTable'; import { TimeSeriesChart } from '../components/TimeSeriesChart'; import { TimeRangeSelector } from '../components/TimeRangeSelector'; +import { useLiveRange } from '../hooks/useLiveRange'; +import { useUrlState } from '../hooks/useUrlState'; import { useSelectionStore } from '../stores/useSelectionStore'; export function ExplorerPage() { - const { selectedMetric, selectedSeries, clearSelectedSeries } = - useSelectionStore(); + const { selectedMetric, selectedSeries } = useSelectionStore(); + useUrlState(); + useLiveRange(); return ( -
- {/* Chart + Time Range — always on top, à la InfluxDB */} +
+ {/* Chart: the window and the drawing are chosen in its header, which keeps the room for the chart */}
-
+

Chart

- {selectedSeries.length > 0 && ( - - {selectedSeries.length} series - - )} -
- -
+ +
-
+
{selectedSeries.length > 0 ? ( ) : ( @@ -42,48 +40,10 @@ export function ExplorerPage() {
{/* Metrics + Series side-by-side below the chart */} -
- {/* Metrics panel */} -
-
-

Metrics

- -
- -
-
- - {/* Series panel */} -
-
-

- {selectedMetric ? ( - <> - Series{' '} - {selectedMetric} - - ) : ( - 'Series' - )} -

- -
-
- {selectedMetric ? ( - - ) : ( -
-

Select a metric to browse series

-
- )} -
-
+
+ + {/* A new metric is a new list: nothing of the sort or the selector stays */} +
); diff --git a/frontend/src/pages/LoadPage.test.tsx b/frontend/src/pages/LoadPage.test.tsx new file mode 100644 index 00000000..7fbd306d --- /dev/null +++ b/frontend/src/pages/LoadPage.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App'; +import { useAuthStore } from '../stores/useAuthStore'; + +// The explorer is the other tab: it finds nothing +const emptyCatalog = { '@context': {}, '@type': 'dcat:Catalog', '@id': 'empty', 'dcat:dataset': [] }; +vi.mock('../client', () => ({ + listMetrics: () => Promise.resolve({ data: emptyCatalog }), + listSeries: () => Promise.resolve({ data: emptyCatalog }), + getSeriesData: () => Promise.resolve({ data: [] }), +})); + +beforeEach(() => { + useAuthStore.setState({ token: null, authRequired: false }); +}); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +const code = () => screen.getByRole('tabpanel').textContent ?? ''; + +describe('LoadPage', () => { + it('is the second tab of the header, next to the explorer', async () => { + const user = userEvent.setup(); + renderAt('/'); + const nav = screen.getByRole('navigation', { name: 'Pages' }); + expect(nav).toHaveTextContent('Data Explorer'); + expect(screen.getByRole('link', { name: 'Data Explorer' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: 'Load Data' })); + expect(await screen.findByRole('tablist', { name: 'Way to load data' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Load Data' })).toHaveAttribute('aria-current', 'page'); + // The Code button is about the explorer + expect(screen.queryByRole('button', { name: 'Code' })).toBeNull(); + }); + + it('opens on the Python SDK, then goes from a way to the other', async () => { + const user = userEvent.setup(); + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Python SDK' })).toHaveAttribute('aria-selected', 'true'); + expect(code()).toContain('from sensapp import SensAppClient'); + expect(code()).toContain('async with SensAppClient('); + + await user.click(screen.getByRole('tab', { name: 'Telegraf' })); + expect(code()).toContain('[[outputs.influxdb_v2]]'); + await user.click(screen.getByRole('tab', { name: 'Prometheus' })); + expect(code()).toContain('remote_write:'); + await user.click(screen.getByRole('tab', { name: 'curl' })); + expect(code()).toContain('/api/v2/write'); + }); + + it('opens on the way the address names, and ignores one it does not know', async () => { + renderAt('/load?via=prometheus'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Prometheus' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('falls back to the Python SDK for an unknown way', async () => { + renderAt('/load?via=cobol'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tab', { name: 'Python SDK' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('goes from a tab to the other with the arrow keys', async () => { + const user = userEvent.setup(); + renderAt('/load'); + await screen.findByRole('tabpanel'); + screen.getByRole('tab', { name: 'Python SDK' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('tab', { name: 'Telegraf' })).toHaveFocus(); + await user.keyboard('{ArrowLeft}{ArrowLeft}'); + expect(screen.getByRole('tab', { name: 'curl' })).toHaveFocus(); + }); + + it('colors the code, and copies it', async () => { + const user = userEvent.setup(); + renderAt('/load?via=curl'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tabpanel').querySelector('span.hljs-string')).not.toBeNull(); + await user.click(screen.getByRole('button', { name: 'Copy SenML JSON' })); + expect(await screen.findByRole('button', { name: 'Copy SenML JSON' })).toHaveTextContent('Copied'); + }); + + it('says how to make a token when the server asks for one, and links to where it is made', async () => { + const user = userEvent.setup(); + useAuthStore.setState({ token: null, authRequired: true }); + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.getByRole('tabpanel')).toHaveTextContent('sensapp generate-token me --scope write'); + expect(code()).toContain('os.environ["SENSAPP_TOKEN"]'); + + // Every way starts with the same section + for (const way of ['Telegraf', 'Prometheus', 'curl']) { + await user.click(screen.getByRole('tab', { name: way })); + expect(screen.getByRole('heading', { name: 'A token' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Make a token in Credentials' })).toHaveAttribute('href', '/credentials'); + } + }); + + it('says nothing of tokens when the server is open', async () => { + renderAt('/load'); + await screen.findByRole('tabpanel'); + expect(screen.queryByRole('link', { name: 'Make a token in Credentials' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'A token' })).toBeNull(); + }); +}); diff --git a/frontend/src/pages/LoadPage.tsx b/frontend/src/pages/LoadPage.tsx new file mode 100644 index 00000000..78fe61c2 --- /dev/null +++ b/frontend/src/pages/LoadPage.tsx @@ -0,0 +1,102 @@ +import { Fragment, useState } from 'react'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { CodeBlock } from '../components/CodeBlock'; +import { LOAD_WAYS, sectionsFor } from '../lib/loadSnippets'; +import type { LoadWay } from '../lib/loadSnippets'; +import { useAuthStore } from '../stores/useAuthStore'; + +/** `code` between backticks is shown as code. */ +function withCode(text: string): ReactNode { + return text.split('`').map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +function isWay(value: string | null): value is LoadWay { + return LOAD_WAYS.some((way) => way.id === value); +} + +/** How to get data into SensApp: a way for each tab, and the code to copy. */ +export function LoadPage() { + const [params, setParams] = useSearchParams(); + const asked = params.get('via'); + const [way, setWay] = useState(isWay(asked) ? asked : 'python'); + const { token, authRequired } = useAuthStore(); + const authenticated = token !== null || authRequired; + + function choose(next: LoadWay) { + setWay(next); + // The way is in the address so that a link shares it, without a history entry for a click + setParams(next === 'python' ? {} : { via: next }, { replace: true }); + } + + // Left and right go from a tab to the other, as in any tab list + function handleTabKey(event: KeyboardEvent) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const index = LOAD_WAYS.findIndex((item) => item.id === way); + const step = event.key === 'ArrowRight' ? 1 : LOAD_WAYS.length - 1; + const next = LOAD_WAYS[(index + step) % LOAD_WAYS.length].id; + choose(next); + document.getElementById(`load-tab-${next}`)?.focus(); + } + + const sections = sectionsFor(way, { + baseUrl: import.meta.env.VITE_SENSAPP_API_URL || window.location.origin, + authenticated, + }); + + return ( +
+
+ {LOAD_WAYS.map((item) => ( + + ))} +
+ +
+
+ {sections.map((section) => ( +
+
+

{section.title}

+

{withCode(section.description)}

+ {section.link && ( + + {section.link.label} + + )} +
+ +
+ ))} +
+
+
+ ); +} + +export default LoadPage; diff --git a/frontend/src/stores/useAuthStore.test.ts b/frontend/src/stores/useAuthStore.test.ts new file mode 100644 index 00000000..661ddc54 --- /dev/null +++ b/frontend/src/stores/useAuthStore.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fakeToken } from '../test/fakeToken'; +import { describeToken, normalizeToken, useAuthStore } from './useAuthStore'; + +describe('useAuthStore', () => { + beforeEach(() => { + sessionStorage.clear(); + useAuthStore.setState({ token: null, authRequired: false, dialogOpen: false, message: null }); + }); + + it('opens the dialog with the answer of the server when a request is refused', () => { + useAuthStore.getState().reject('Missing or invalid Authorization header'); + const state = useAuthStore.getState(); + expect(state.dialogOpen).toBe(true); + expect(state.authRequired).toBe(true); + expect(state.message).toBe('Missing or invalid Authorization header'); + }); + + it('signs in with a token, closes the dialog and forgets the complaint', () => { + useAuthStore.getState().reject('Invalid token: ExpiredSignature'); + useAuthStore.getState().signIn(' Bearer abc.def.ghi '); + const state = useAuthStore.getState(); + expect(state.token).toBe('abc.def.ghi'); + expect(state.dialogOpen).toBe(false); + expect(state.message).toBeNull(); + }); + + it('signs out but remembers that the server wants a token', () => { + useAuthStore.getState().reject('no'); + useAuthStore.getState().signIn('abc'); + useAuthStore.getState().signOut(); + expect(useAuthStore.getState().token).toBeNull(); + expect(useAuthStore.getState().authRequired).toBe(true); + }); + + it('keeps the token in sessionStorage only, and nothing else', () => { + useAuthStore.getState().reject('no'); + useAuthStore.getState().signIn('abc.def.ghi'); + expect(sessionStorage.getItem('sensapp-auth')).toContain('abc.def.ghi'); + expect(localStorage.getItem('sensapp-auth')).toBeNull(); + const stored = JSON.parse(sessionStorage.getItem('sensapp-auth')!); + expect(Object.keys(stored.state)).toEqual(['token']); + }); + + it('can be reopened and closed by hand', () => { + useAuthStore.getState().openDialog(); + expect(useAuthStore.getState().dialogOpen).toBe(true); + useAuthStore.getState().closeDialog(); + expect(useAuthStore.getState().dialogOpen).toBe(false); + }); +}); + +describe('normalizeToken', () => { + it('strips what comes with a pasted token', () => { + expect(normalizeToken('abc')).toBe('abc'); + expect(normalizeToken('Bearer abc\n')).toBe('abc'); + expect(normalizeToken('bearer abc')).toBe('abc'); + expect(normalizeToken('"abc"')).toBe('abc'); + expect(normalizeToken(' ')).toBe(''); + }); +}); + +describe('describeToken', () => { + it('reads the claims of a token', () => { + const info = describeToken(fakeToken({ sub: 'alice', scope: 'read write', exp: 4_102_444_800 })); + expect(info).toEqual({ + subject: 'alice', + scope: 'read write', + expires: new Date(4_102_444_800_000), + }); + }); + + it('reads a subject that is not ASCII', () => { + expect(describeToken(fakeToken({ sub: 'Zoë' }))?.subject).toBe('Zoë'); + }); + + it('returns null for what is not a token', () => { + expect(describeToken('')).toBeNull(); + expect(describeToken('no-dots')).toBeNull(); + expect(describeToken('a.%%%.c')).toBeNull(); + expect(describeToken('a.bm90LWpzb24.c')).toBeNull(); + }); +}); diff --git a/frontend/src/stores/useAuthStore.ts b/frontend/src/stores/useAuthStore.ts new file mode 100644 index 00000000..fb724402 --- /dev/null +++ b/frontend/src/stores/useAuthStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface AuthState { + /** The JWT sent as `Authorization: Bearer`. Kept for this browser tab only. */ + token: string | null; + /** Set when the server asked for a token, to offer signing in. */ + authRequired: boolean; + dialogOpen: boolean; + /** What the server answered to the refused request, shown in the dialog. */ + message: string | null; + signIn: (token: string) => void; + signOut: () => void; + /** The server answered 401 or 403. */ + reject: (message: string) => void; + openDialog: () => void; + closeDialog: () => void; +} + +/** What people paste: the token alone, or with the `Bearer ` prefix or quotes around it. */ +export function normalizeToken(input: string): string { + return input + .trim() + .replace(/^bearer\s+/i, '') + .replace(/^["']|["']$/g, '') + .trim(); +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + authRequired: false, + dialogOpen: false, + message: null, + + signIn: (token) => + set({ token: normalizeToken(token), dialogOpen: false, message: null }), + signOut: () => set({ token: null, message: null }), + reject: (message) => + set({ authRequired: true, dialogOpen: true, message }), + openDialog: () => set({ dialogOpen: true, message: null }), + closeDialog: () => set({ dialogOpen: false }), + }), + { + name: 'sensapp-auth', + // sessionStorage: gone when the tab closes, not shared with the other tabs + storage: createJSONStorage(() => sessionStorage), + partialize: ({ token }) => ({ token }), + }, + ), +); + +export interface TokenInfo { + subject?: string; + scope?: string; + expires?: Date; +} + +/** + * What the token says about itself, to show who is signed in. Nothing is verified here: the + * server does that, and answers 401 when the token is wrong. + */ +export function describeToken(token: string): TokenInfo | null { + try { + const payload = token.split('.')[1]; + if (!payload) return null; + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const claims = JSON.parse(new TextDecoder().decode(bytes)) as Record; + return { + subject: typeof claims.sub === 'string' ? claims.sub : undefined, + scope: typeof claims.scope === 'string' ? claims.scope : undefined, + expires: typeof claims.exp === 'number' ? new Date(claims.exp * 1000) : undefined, + }; + } catch { + return null; + } +} diff --git a/frontend/src/stores/useSelectionStore.test.ts b/frontend/src/stores/useSelectionStore.test.ts index 22b96406..2f62bcae 100644 --- a/frontend/src/stores/useSelectionStore.test.ts +++ b/frontend/src/stores/useSelectionStore.test.ts @@ -11,6 +11,15 @@ describe('useSelectionStore', () => { setLabelFilter(''); }); + it('forgets the selector of the series when the metric changes: it was written for the others', () => { + const { result } = renderHook(() => useSelectionStore()); + act(() => result.current.setSelectedMetric('cpu')); + act(() => result.current.setSelector('{host="a"}')); + expect(result.current.selector).toBe('{host="a"}'); + act(() => result.current.setSelectedMetric('memory')); + expect(result.current.selector).toBe(''); + }); + it('should have sensible default state', () => { const { result } = renderHook(() => useSelectionStore()); expect(result.current.selectedMetric).toBeNull(); @@ -105,4 +114,89 @@ describe('useSelectionStore', () => { }); expect(result.current.selectedSeries).toHaveLength(0); }); + + describe('colors', () => { + const info = (uuid: string) => ({ uuid, name: 'cpu', labels: {}, type: 'float' }); + const slots = () => useSelectionStore.getState().selectedSeries.map((s) => [s.uuid, s.slot]); + + it('keeps the color of a series as long as it is selected', () => { + const { toggleSeries } = useSelectionStore.getState(); + act(() => ['a', 'b', 'c'].forEach((uuid) => toggleSeries(info(uuid)))); + expect(slots()).toEqual([['a', 0], ['b', 1], ['c', 2]]); + + // The first one goes: the others keep theirs, and the next one takes the free one + act(() => toggleSeries(info('a'))); + expect(slots()).toEqual([['b', 1], ['c', 2]]); + act(() => toggleSeries(info('d'))); + expect(slots()).toEqual([['b', 1], ['c', 2], ['d', 0]]); + }); + + it('selects many series at once, those that are already selected staying as they are', () => { + const { toggleSeries, selectSeries } = useSelectionStore.getState(); + act(() => toggleSeries(info('b'))); + act(() => selectSeries([info('a'), info('b'), info('c')])); + expect(slots()).toEqual([['b', 0], ['a', 1], ['c', 2]]); + }); + }); + + describe('the window', () => { + const typed = { start: '2026-10-01T00:00:00.000Z', end: '2026-10-01T01:00:00.000Z' }; + const state = () => useSelectionStore.getState(); + + beforeEach(() => { + useSelectionStore.setState({ rangeHistory: [] }); + state().setRelativeRange('1h'); + useSelectionStore.setState({ rangeHistory: [] }); + }); + + it('goes back to the windows the user left, the last one first', () => { + act(() => state().setTimeRange(typed.start, typed.end)); + act(() => state().panRange(-1)); + expect(state().timeRange.start).toBe('2026-09-30T23:30:00.000Z'); + + act(() => state().undoRange()); + expect(state().timeRange).toEqual(typed); + act(() => state().undoRange()); + // The first window was the last hour: it is the last hour as of now + expect(state().relativeRange).toBe('1h'); + expect(Date.parse(state().timeRange.end)).toBeGreaterThan(Date.now() - 5000); + expect(state().rangeHistory).toEqual([]); + // Nothing more to go back to + act(() => state().undoRange()); + expect(state().relativeRange).toBe('1h'); + }); + + it('does not keep what the clock does, nor the same preset again', () => { + act(() => state().refreshRelativeRange()); + act(() => state().setRelativeRange('1h')); + expect(state().rangeHistory).toEqual([]); + act(() => state().setRelativeRange('6h')); + expect(state().rangeHistory).toHaveLength(1); + }); + + it('remembers 20 windows', () => { + for (let i = 0; i < 25; i++) act(() => state().panRange(-1)); + expect(state().rangeHistory).toHaveLength(20); + }); + + it('pans to a window of dates, and zooms out of it', () => { + act(() => state().panRange(-1)); + expect(state().relativeRange).toBeNull(); + act(() => state().setTimeRange(typed.start, typed.end)); + act(() => state().zoomOutRange()); + expect(state().timeRange).toEqual({ start: '2026-09-30T23:30:00.000Z', end: '2026-10-01T01:30:00.000Z' }); + }); + + it('zooms out of a live window into the next preset, still live', () => { + act(() => state().zoomOutRange()); + expect(state().relativeRange).toBe('6h'); + const hours = (Date.parse(state().timeRange.end) - Date.parse(state().timeRange.start)) / 3_600_000; + expect(hours).toBe(6); + act(() => state().setRelativeRange('30d')); + const before = state().rangeHistory.length; + act(() => state().zoomOutRange()); + expect(state().relativeRange).toBe('30d'); + expect(state().rangeHistory).toHaveLength(before); + }); + }); }); diff --git a/frontend/src/stores/useSelectionStore.ts b/frontend/src/stores/useSelectionStore.ts index ddc186c8..67ab28bb 100644 --- a/frontend/src/stores/useSelectionStore.ts +++ b/frontend/src/stores/useSelectionStore.ts @@ -1,25 +1,85 @@ import { create } from 'zustand'; +import type { Aggregation } from '../lib/chartStep'; +import type { ChartStyle } from '../lib/chartOption'; +import { freeSlot } from '../lib/palette'; +import { DEFAULT_RANGE, nextPreset, panned, rangeFor, zoomedOut } from '../lib/timeRange'; +import type { TimeRange } from '../lib/timeRange'; -interface SelectedSeries { +/** What the server says of a series. */ +export interface SeriesInfo { uuid: string; name: string; labels: Record; type: string; } +/** A series that is shown, with the color slot it keeps for as long as it is. */ +export interface SelectedSeries extends SeriesInfo { + slot: number; +} + +/** A time window, and the preset it comes from if it has one. */ +export interface WindowState { + timeRange: TimeRange; + relativeRange: string | null; +} + +const HISTORY_SIZE = 20; + +/** The series with a slot each, the ones that have one keeping it. */ +export function withSlots(series: SeriesInfo[], selected: SelectedSeries[] = []): SelectedSeries[] { + const result = [...selected]; + for (const info of series) { + if (result.some((s) => s.uuid === info.uuid)) continue; + result.push({ ...info, slot: freeSlot(result.map((s) => s.slot)) }); + } + return result; +} + interface SelectionState { selectedMetric: string | null; selectedSeries: SelectedSeries[]; - timeRange: { - start: string; - end: string; - }; + /** The series under the pointer in the list, which the chart highlights */ + hoveredSeries: string | null; + timeRange: TimeRange; + /** The preset the range comes from (`24h`), `null` once dates were typed */ + relativeRange: string | null; + /** The windows the user left, the last one first: what Back goes to. The clock moving a preset is not one. */ + rangeHistory: WindowState[]; + chartStyle: ChartStyle; + logScale: boolean; + /** `auto` (the range decides), `raw`, or a duration such as `5m` */ + step: string; + aggregation: Aggregation; labelFilter: string; + /** What is typed in the selector box of the series (`{host="a"}`): the server lists the series that match */ + selector: string; setSelectedMetric: (metric: string | null) => void; - toggleSeries: (series: SelectedSeries) => void; + toggleSeries: (series: SeriesInfo) => void; + /** Shows these series too */ + selectSeries: (series: SeriesInfo[]) => void; + /** Hides these series, the others keep their colors */ + unselectSeries: (series: SeriesInfo[]) => void; clearSelectedSeries: () => void; + setHoveredSeries: (uuid: string | null) => void; + /** A window of dates: typed, or brushed on the chart */ setTimeRange: (start: string, end: string) => void; + /** A preset: the range ending now */ + setRelativeRange: (label: string) => void; + /** The end of a preset moves with the clock. Nothing is kept of it in the history. */ + refreshRelativeRange: () => void; + /** The window half a window earlier or later */ + panRange: (direction: -1 | 1) => void; + /** Twice the window, a live preset going to the next one */ + zoomOutRange: () => void; + /** Back to the window before the last change of the user */ + undoRange: () => void; + setChartStyle: (style: ChartStyle) => void; + setLogScale: (log: boolean) => void; setLabelFilter: (filter: string) => void; + setSelector: (selector: string) => void; + setStep: (step: string) => void; + setAggregation: (aggregation: Aggregation) => void; } function defaultTimeRange() { @@ -31,14 +91,26 @@ function defaultTimeRange() { }; } +function remember(state: SelectionState): WindowState[] { + return [{ timeRange: state.timeRange, relativeRange: state.relativeRange }, ...state.rangeHistory].slice(0, HISTORY_SIZE); +} + export const useSelectionStore = create((set) => ({ selectedMetric: null, selectedSeries: [], + hoveredSeries: null, timeRange: defaultTimeRange(), + relativeRange: DEFAULT_RANGE, + rangeHistory: [], + chartStyle: 'line', + logScale: false, labelFilter: '', + selector: '', + step: 'auto', + aggregation: 'avg', setSelectedMetric: (metric) => - set({ selectedMetric: metric, selectedSeries: [] }), + set({ selectedMetric: metric, selectedSeries: [], hoveredSeries: null, selector: '' }), toggleSeries: (series) => set((state) => { @@ -50,14 +122,80 @@ export const useSelectionStore = create((set) => ({ ), }; } - return { - selectedSeries: [...state.selectedSeries, series], - }; + return { selectedSeries: withSlots([series], state.selectedSeries) }; }), + selectSeries: (series) => + set((state) => ({ selectedSeries: withSlots(series, state.selectedSeries) })), + + unselectSeries: (series) => + set((state) => ({ + selectedSeries: state.selectedSeries.filter((s) => !series.some((gone) => gone.uuid === s.uuid)), + })), + clearSelectedSeries: () => set({ selectedSeries: [] }), - setTimeRange: (start, end) => set({ timeRange: { start, end } }), + setHoveredSeries: (hoveredSeries) => set({ hoveredSeries }), + + setTimeRange: (start, end) => + set((state) => ({ + timeRange: { start, end }, + relativeRange: null, + rangeHistory: remember(state), + })), + + setRelativeRange: (label) => + set((state) => { + const timeRange = rangeFor(label); + if (!timeRange) return {}; + // The same preset again is a refresh: there is nothing to go back to + const same = state.relativeRange === label; + return { timeRange, relativeRange: label, ...(!same && { rangeHistory: remember(state) }) }; + }), + + refreshRelativeRange: () => + set((state) => { + const timeRange = state.relativeRange ? rangeFor(state.relativeRange) : undefined; + return timeRange ? { timeRange } : {}; + }), + + panRange: (direction) => + set((state) => ({ + timeRange: panned(state.timeRange, direction), + relativeRange: null, + rangeHistory: remember(state), + })), + + zoomOutRange: () => + set((state) => { + if (state.relativeRange) { + const label = nextPreset(state.relativeRange); + const timeRange = rangeFor(label); + return label === state.relativeRange || !timeRange + ? {} + : { timeRange, relativeRange: label, rangeHistory: remember(state) }; + } + return { timeRange: zoomedOut(state.timeRange), rangeHistory: remember(state) }; + }), + + undoRange: () => + set((state) => { + const [previous, ...rest] = state.rangeHistory; + if (!previous) return {}; + // A preset is the last hour again, as of now + const timeRange = (previous.relativeRange && rangeFor(previous.relativeRange)) || previous.timeRange; + return { timeRange, relativeRange: previous.relativeRange, rangeHistory: rest }; + }), + + setChartStyle: (chartStyle) => set({ chartStyle }), + + setLogScale: (logScale) => set({ logScale }), setLabelFilter: (filter) => set({ labelFilter: filter }), + + setSelector: (selector) => set({ selector }), + + setStep: (step) => set({ step }), + + setAggregation: (aggregation) => set({ aggregation }), })); diff --git a/frontend/src/test/fakeToken.ts b/frontend/src/test/fakeToken.ts new file mode 100644 index 00000000..ef5afcc2 --- /dev/null +++ b/frontend/src/test/fakeToken.ts @@ -0,0 +1,11 @@ +/** A JWT with these claims, for tests. The UI does not check the signature, the server does. */ +export function fakeToken(claims: Record): string { + const encode = (value: unknown) => { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + }; + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`; +} diff --git a/frontend/src/test/setup.tsx b/frontend/src/test/setup.tsx index a3baa90c..d160f866 100644 --- a/frontend/src/test/setup.tsx +++ b/frontend/src/test/setup.tsx @@ -1,14 +1,22 @@ import '@testing-library/jest-dom/vitest'; -// Mock echarts-for-react globally since it requires canvas in jsdom -vi.mock('echarts-for-react', () => ({ - default: (props: { option: unknown }) => { - const seriesCount = Array.isArray((props.option as Record)?.series) - ? ((props.option as Record).series as unknown[]).length - : 0; +// echarts needs a canvas, which jsdom does not have. +vi.mock('../components/EChart', () => ({ + default: (props: { option: unknown; onBrush?: (fromMs: number, toMs: number) => void; highlight?: string | null }) => { + const series = (props.option as { series?: unknown[] })?.series; return ( -
+
Chart + {/* A drag on the chart, from 00:10 to 00:20 on 2026-10-04 */} +
); }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fa211c79..10e00741 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,43 +1,23 @@ import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +// The API lives at the root of SensApp; `npm run dev` proxies it. +const SENSAPP = 'http://localhost:3000' +const API_PATHS = ['/api', '/health', '/metrics', '/series', '/docs'] + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + // SensApp serves the built files under /ui/ (src/http/ui.rs), and `npm run dev` does too. + base: '/ui/', build: { - rollupOptions: { - output: { - manualChunks: { - echarts: ['echarts', 'echarts-for-react'], - react: ['react', 'react-dom', 'react-router-dom'], - query: ['@tanstack/react-query'], - }, - }, - }, + // echarts alone is about 580 kB, and only loaded when a chart is drawn. + chunkSizeWarningLimit: 650, }, server: { - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/metrics': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/series': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/health': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/docs': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - }, + proxy: Object.fromEntries( + API_PATHS.map((path) => [path, { target: SENSAPP, changeOrigin: true }]), + ), }, }) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 67bc81a7..fe8f8504 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], diff --git a/ideas/frontend-next-steps.md b/ideas/frontend-next-steps.md new file mode 100644 index 00000000..dd39c6f5 --- /dev/null +++ b/ideas/frontend-next-steps.md @@ -0,0 +1,29 @@ +# Frontend: next steps + +What was left out of `done/frontend-good-enough.md` on purpose, most useful first. + +- **Strings and locations.** Listed but not drawn. A table of the values over the range, or the latest value + (`/series/{uuid}/last`), would show strings; locations want a map. +- **Several instances, one UI.** The API base is `VITE_SENSAPP_API_URL` at build time. A runtime setting would + allow one build against any instance; only useful if the UI is ever served apart from SensApp. +- **TypeScript 7.** Held at 5.9 until `typescript-eslint` supports it (peer range `<6.1`). +- The DCAT types of `src/hooks/useMetrics.ts` and `useSeries.ts` are written by hand because the OpenAPI + document types the catalogs as a plain string. Typing them in the server would remove the hand-written copy. +- **Expression box (PromQL).** Decided against for now: the series browser with step and aggregation covers one + series at a time, and the box would only add combining series (`avg by (room)`, through `/api/v1/query`). + Revisit when someone asks for that. +- Not planned: dashboards, alerting, a query builder (Grafana's job). +- **An overview of the whole life of the series** in place of the echarts slider that was removed (the drag on the + chart and the bar below it do the zooming now): `/series/{uuid}/availability` could say where there is data, as + a thin strip above the time bar, clicking in it sets the window. A third thing to look at, so only if people + lose their way in long histories. +- **Wheel zoom and pan by drag** on the chart. The drag is a selection (as in Grafana) and the wheel scrolls the + page on small screens; `‹ ›` and `−` cover them. +- **More than 24 series** repeat colors. Past 8 the colors cannot say which is which anyway (see + `docs/FRONTEND.md`); small multiples, or a warning, would be the answer. +- **Auto-refresh** was declined at first, but a preset range now follows the clock every minute (the end is now), + which is the same thing for the chart. A refresh switch would only be needed to stop it. +- **More in the Code dialog.** Only reading is covered (Python, curl). Left out on purpose: writing (`publish`), other + languages (JavaScript, R), a PromQL query (`client.query('cpu{host="a"}[24h]')`, which would also work for a + window that follows the clock), and a Polars/pandas plot at the end of the Python code. A format choice for curl + (SenML and JSON Lines besides CSV) if someone needs it. diff --git a/ideas/influxdb-labels-without-tags.md b/ideas/influxdb-labels-without-tags.md new file mode 100644 index 00000000..57be18cd --- /dev/null +++ b/ideas/influxdb-labels-without-tags.md @@ -0,0 +1,9 @@ +# InfluxDB importer: org and bucket are only added to lines that have tags + +`src/http/influxdb.rs` adds `influxdb_org` and `influxdb_bucket` to the labels of a line, on purpose: they are +part of the identity of a series (the same measurement written to two buckets is two series). But it does so only +`Some(tags)`: a line without tags (`power_w value=5`) gets no labels at all, so the same measurement without tags +written to two buckets or two orgs is one series. + +Probably an oversight rather than a decision. Either add the two labels to every line (a breaking change for +the ids of the series that already exist, which is fine before production), or write it down as intended. diff --git a/ideas/metrics-tests-need-loaded-config.md b/ideas/metrics-tests-need-loaded-config.md new file mode 100644 index 00000000..1dfe4174 --- /dev/null +++ b/ideas/metrics-tests-need-loaded-config.md @@ -0,0 +1,7 @@ +# Two `http::metrics` unit tests only pass when another test loaded the configuration + +`http::metrics::tests::latest_prometheus_line_uses_original_timestamp` and +`scrape_compatibility_requires_numeric_sensor_and_valid_names` call `config::get()`, which is only set once some +test ran `load_configuration_for_tests()`. The full `cargo test --lib` passes, but +`cargo test --lib http::metrics` (or `http::`) alone fails with "Configuration not loaded". Seen on `main` on +4 October 2026. Call `load_configuration_for_tests()` at the start of both tests. diff --git a/ideas/string-series-aggregation-500.md b/ideas/string-series-aggregation-500.md new file mode 100644 index 00000000..1511f0fc --- /dev/null +++ b/ideas/string-series-aggregation-500.md @@ -0,0 +1,11 @@ +# `aggregation` on a string series answers 500 + +Found on 4 October 2026 while building the chart of the web UI. + +`GET /series/{uuid}?step=1m&aggregation=avg` on a string series (a sensor published with `vs`) answers +`500 Internal Server Error`, on SQLite at least. The same request without `step` answers 200. An average of +strings is a client mistake, so the answer should be a `400` that says which types an aggregation supports, +and it should be the same on every backend. Add a backend-generic test (string and boolean series with +every aggregation) next to the other series query tests. + +The UI works around it: it only aggregates `float`, `integer` and `numeric` series. diff --git a/ideas/token-registry-and-revocation.md b/ideas/token-registry-and-revocation.md new file mode 100644 index 00000000..4c38d534 --- /dev/null +++ b/ideas/token-registry-and-revocation.md @@ -0,0 +1,14 @@ +# Token registry: list and revoke single tokens + +The authentication work of October 2026 kept tokens **stateless** on purpose: SensApp signs them and keeps nothing. The price is that a token cannot be listed, and cannot be revoked one by one: only rotating the secret (`SENSAPP_JWT_PREVIOUS_SECRETS`) revokes, and it revokes every token the secret signed. The Credentials tab can only make tokens. + +The option that was left out, if one-by-one management is wanted later: + +- Every token already carries a `jti` (and the logs have it), so the hook exists. +- A `credentials` table in the main storage: `jti`, subject, scope, sensors, `created_at`, `expires_at`, creator (the `jti` of the admin token that asked), `revoked_at`. `POST /api/v1/admin/tokens` inserts a row; `GET /api/v1/admin/tokens` lists them; `DELETE /api/v1/admin/tokens/{jti}` sets `revoked_at`. +- Checking a request must not cost a query: each instance keeps the set of revoked `jti` in memory and refreshes it every few seconds (a bounded delay, safe with several instances, which a per-process list would not be). Tokens of the command line have a `jti` too and are not registered: they are valid unless their `jti` is revoked, which works as a denylist. +- Backends: the SQL ones (PostgreSQL, TimescaleDB, SQLite, DuckDB) and ClickHouse (insert-only events, a `ReplacingMergeTree` or a revocations table) can hold it; BigQuery and rrdcached cannot (or should not), so the Credentials tab would fall back to create-only there, as now. +- Concurrent writers: revoking is idempotent and creating has a fresh UUID, so two instances cannot conflict; test it anyway with two routers on one database. +- UI: a table in the Credentials tab (name, scope, sensors, expiry, creator, a Revoke button). + +Decide when someone needs to revoke a single client without cutting the others off. diff --git a/python/quickstart.ipynb b/python/quickstart.ipynb index 5c5b12ed..b8986e18 100644 --- a/python/quickstart.ipynb +++ b/python/quickstart.ipynb @@ -9,10 +9,10 @@ "\n", "Store a small temperature dataset, fetch it with Prometheus-style label filters, and plot it. Each run uses a new `run` label, so rerunning the notebook keeps earlier data.\n", "\n", - "From the repository root, start SensApp in one terminal:\n", + "From the repository root, start SensApp in one terminal (without authentication, as this notebook sends no token; see `docs/JWT_AUTH.md` to use one):\n", "\n", "```bash\n", - "SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db cargo run\n", + "SENSAPP_AUTH_DISABLED=true SENSAPP_STORAGE_CONNECTION_STRING=sqlite://sensapp.db cargo run\n", "```\n", "\n", "In another terminal, install the notebook dependencies with [uv](https://docs.astral.sh/uv/) and open it:\n", diff --git a/settings.toml b/settings.toml index 8b2f5a24..4dffa329 100644 --- a/settings.toml +++ b/settings.toml @@ -18,6 +18,11 @@ storage_connection_string = "postgres://postgres:postgres@localhost:5432/sensapp # Maximum concurrent write requests, extra writes get 503 + a jittered Retry-After (default: 16, 0 = no limit) # http_max_concurrent_writes = 16 +# Web UI, served under /ui/ with / redirecting to it (default: true). It needs the built frontend +# (npm run build in frontend/); the container image ships it. See docs/FRONTEND.md. +# ui_enabled = true +# ui_dir = "frontend/dist" + # Sentry DSN for error tracking (leave commented to disable) # sentry_dsn = "https://your-dsn@your-sentry-instance/project-id" diff --git a/src/config/mod.rs b/src/config/mod.rs index 5cc33573..a10f1dc6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -41,6 +41,16 @@ pub struct SensAppConfig { #[config(env = "SENSAPP_HTTP_MAX_CONCURRENT_WRITES", default = 16)] pub http_max_concurrent_writes: usize, + /// Serve the web UI under `/ui/`, and redirect `/` to it. The UI is only the static files of + /// `frontend/`: the data stays behind the same authentication as the API. + #[config(env = "SENSAPP_UI_ENABLED", default = true)] + pub ui_enabled: bool, + + /// Directory with the built UI (`frontend/dist`). The container image sets it to where it + /// ships the files. When it has no `index.html` the UI is not served and a warning is logged. + #[config(env = "SENSAPP_UI_DIR", default = "frontend/dist")] + pub ui_dir: String, + #[config(env = "SENSAPP_MAX_INFERENCES_ROWS", default = 128)] pub max_inference_rows: usize, @@ -69,12 +79,32 @@ pub struct SensAppConfig { #[config(env = "SENSAPP_INFLUXDB_WITH_NUMERIC", default = false)] pub influxdb_with_numeric: bool, - /// JWT secret for optional authentication. - /// When set, all protected endpoints require a valid JWT bearer token. - /// Must be at least 32 characters long. - /// When unset, all endpoints are open (no security). + /// Secret that signs and verifies the JWTs. Must be at least 32 characters long + /// (`sensapp generate-secret` makes one). When set, the protected endpoints require a valid + /// JWT bearer token. + /// + /// When unset, SensApp does not run open by default: on a loopback address it makes a random + /// secret for this run and prints an admin token, on any other address it refuses to start, + /// unless `SENSAPP_AUTH_DISABLED` is true. #[config(env = "SENSAPP_JWT_SECRET")] pub jwt_secret: Option, + + /// Previous secrets, separated by commas, that still verify tokens but never sign them: the + /// way to rotate `SENSAPP_JWT_SECRET`. Put the new secret in `SENSAPP_JWT_SECRET` and the + /// old one here until its tokens have expired, then remove it. Dropping a secret refuses + /// every token it signed, which is also how tokens are revoked. + #[config(env = "SENSAPP_JWT_PREVIOUS_SECRETS")] + pub jwt_previous_secrets: Option, + + /// Longest validity, in seconds, of a token made with `POST /api/v1/admin/tokens` (one year + /// by default). `sensapp generate-token` has the secret and is not capped by it. + #[config(env = "SENSAPP_TOKEN_MAX_DURATION_SECONDS", default = 31536000)] + pub token_max_duration_seconds: u64, + + /// Run without authentication: every endpoint is open. The explicit opt-out for demos and + /// networks that authenticate in front of SensApp. Ignored when `SENSAPP_JWT_SECRET` is set. + #[config(env = "SENSAPP_AUTH_DISABLED", default = false)] + pub auth_disabled: bool, } impl SensAppConfig { @@ -88,6 +118,18 @@ impl SensAppConfig { Ok(c) } + /// The secrets of `SENSAPP_JWT_PREVIOUS_SECRETS`, one per comma-separated item. + pub fn previous_jwt_secrets(&self) -> Vec { + self.jwt_previous_secrets + .as_deref() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|secret| !secret.is_empty()) + .map(str::to_string) + .collect() + } + pub fn parse_http_body_limit(&self) -> Result { let size = byte_unit::Byte::parse_str(self.http_body_limit.clone(), true)?.as_u64(); if size > MAX_HTTP_BODY_LIMIT_BYTES { @@ -152,6 +194,8 @@ mod tests { assert_eq!(config.port, 3000); assert_eq!(config.endpoint, IpAddr::from([127, 0, 0, 1])); + assert!(config.ui_enabled); + assert_eq!(config.ui_dir, "frontend/dist"); temp_env::with_var("SENSAPP_PORT", Some("8080"), || { let config = SensAppConfig::load().unwrap(); @@ -159,6 +203,22 @@ mod tests { }); } + #[test] + fn test_previous_jwt_secrets() { + temp_env::with_var("SENSAPP_JWT_PREVIOUS_SECRETS", None::<&str>, || { + assert!( + SensAppConfig::load() + .unwrap() + .previous_jwt_secrets() + .is_empty() + ); + }); + temp_env::with_var("SENSAPP_JWT_PREVIOUS_SECRETS", Some(" one ,two,, "), || { + let secrets = SensAppConfig::load().unwrap().previous_jwt_secrets(); + assert_eq!(secrets, vec!["one".to_string(), "two".to_string()]); + }); + } + #[test] fn test_parse_http_body_limit() { let config = SensAppConfig::load().unwrap(); diff --git a/src/http/auth.rs b/src/http/auth.rs index ea63692a..9a0f58b1 100644 --- a/src/http/auth.rs +++ b/src/http/auth.rs @@ -3,12 +3,26 @@ use axum::extract::State; use axum::http::{HeaderMap, header}; use axum::middleware::Next; use axum::response::Response; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::sync::Arc; use super::app_error::AppError; +/// Issuer and audience of the tokens SensApp makes, checked on the tokens it receives: a token +/// of another system, signed with a secret that was reused, is refused. +pub const TOKEN_ISSUER: &str = "sensapp"; +pub const TOKEN_AUDIENCE: &str = "sensapp"; + +const MIN_SECRET_LENGTH: usize = 32; + +/// Longest token the HTTP endpoint makes unless configured otherwise: one year. +pub const DEFAULT_MAX_TOKEN_DURATION_SECONDS: u64 = 365 * 24 * 3600; + // --------------------------------------------------------------------------- // Auth configuration // --------------------------------------------------------------------------- @@ -17,11 +31,20 @@ use super::app_error::AppError; /// /// When present in the server state, all protected endpoints require a valid /// JWT bearer token. When absent (`None`), all endpoints are open. +/// +/// Tokens are signed with one secret and name it in their `kid` header, so that the secrets of +/// a rotation (the new one signs, the previous ones still verify) are told apart. #[derive(Clone)] pub struct AuthConfig { - decoding_key: Arc, + signing_kid: Arc, encoding_key: Arc, + /// The key of the signing secret, which verifies the tokens that have no `kid`. + decoding_key: Arc, + /// Every key that verifies tokens by `kid`: the signing secret and the previous ones. + decoding_keys: Arc>, validation: Arc, + /// Longest validity of the tokens the HTTP endpoint makes. + max_token_duration_seconds: u64, } impl std::fmt::Debug for AuthConfig { @@ -30,53 +53,193 @@ impl std::fmt::Debug for AuthConfig { } } +/// Name of a secret: the first 4 bytes of its SHA-256, in hexadecimal. It is in the header of +/// the tokens, which anyone can read, and does not give the secret away. +fn key_id(secret: &str) -> String { + Sha256::digest(secret.as_bytes()) + .iter() + .take(4) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn check_secret_length(secret: &str) -> Result<(), anyhow::Error> { + if secret.len() < MIN_SECRET_LENGTH { + anyhow::bail!("JWT secret must be at least {MIN_SECRET_LENGTH} characters long"); + } + Ok(()) +} + +/// A token that was just made. +#[derive(Clone, Debug)] +pub struct IssuedToken { + pub token: String, + /// Unique id of the token, in its `jti` claim and in the logs. + pub jti: String, + /// Expiration time, Unix timestamp. + pub expires_at: u64, +} + impl AuthConfig { /// Create an AuthConfig from an HMAC secret (HS256). /// /// The secret must be at least 32 characters long. pub fn from_secret(secret: &str) -> Result { - if secret.len() < 32 { - anyhow::bail!("JWT secret must be at least 32 characters long"); - } + check_secret_length(secret)?; let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = true; validation.validate_nbf = true; - validation.set_required_spec_claims(&["exp", "sub"]); + validation.set_required_spec_claims(&["exp", "sub", "iss", "aud"]); + validation.set_issuer(&[TOKEN_ISSUER]); + validation.set_audience(&[TOKEN_AUDIENCE]); + let kid = key_id(secret); + let decoding_key = DecodingKey::from_secret(secret.as_bytes()); Ok(Self { - decoding_key: Arc::new(DecodingKey::from_secret(secret.as_bytes())), + signing_kid: kid.as_str().into(), encoding_key: Arc::new(EncodingKey::from_secret(secret.as_bytes())), + decoding_key: Arc::new(decoding_key.clone()), + decoding_keys: Arc::new(HashMap::from([(kid, decoding_key)])), validation: Arc::new(validation), + max_token_duration_seconds: DEFAULT_MAX_TOKEN_DURATION_SECONDS, }) } - /// Create a signed JWT token. - pub fn create_token( + /// Cap the validity of the tokens made through the HTTP endpoint (the command line has the + /// secret, so it is not capped by this). + pub fn with_max_token_duration(mut self, seconds: u64) -> Self { + self.max_token_duration_seconds = seconds; + self + } + + pub fn max_token_duration_seconds(&self) -> u64 { + self.max_token_duration_seconds + } + + /// Also verify the tokens signed with these previous secrets, which never sign. This is how + /// a secret is rotated without refusing the tokens of the clients at once: the new secret + /// signs, the old one is kept here until its tokens have expired, then dropped. + pub fn with_previous_secrets(mut self, secrets: &[String]) -> Result { + let keys = Arc::make_mut(&mut self.decoding_keys); + for secret in secrets { + check_secret_length(secret).map_err(|_| { + anyhow::anyhow!( + "SENSAPP_JWT_PREVIOUS_SECRETS: each secret must be at least \ + {MIN_SECRET_LENGTH} characters long" + ) + })?; + keys.insert(key_id(secret), DecodingKey::from_secret(secret.as_bytes())); + } + Ok(self) + } + + /// Create a signed JWT token, with a `jti` of its own. + pub fn issue_token( &self, sub: &str, scope: &str, duration_seconds: u64, sensors: Option>, - ) -> Result { + ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs(); + let expires_at = now + .checked_add(duration_seconds) + .ok_or_else(|| anyhow::anyhow!("The duration of the token is too long"))?; + let jti = uuid::Uuid::new_v4().to_string(); let claims = Claims { sub: sub.to_string(), - exp: now + duration_seconds, + exp: expires_at, iat: Some(now), nbf: Some(now), scope: Some(scope.to_string()), sensors, + iss: Some(TOKEN_ISSUER.to_string()), + aud: Some(TOKEN_AUDIENCE.to_string()), + jti: Some(jti.clone()), }; - let token = encode(&Header::default(), &claims, &self.encoding_key)?; - Ok(token) + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(self.signing_kid.to_string()); + let token = encode(&header, &claims, &self.encoding_key)?; + Ok(IssuedToken { + token, + jti, + expires_at, + }) } } +// --------------------------------------------------------------------------- +// Startup mode +// --------------------------------------------------------------------------- + +/// How the server authenticates, decided once at startup by [`resolve_auth_mode`]. +#[derive(Clone, PartialEq, Eq)] +pub enum AuthMode { + /// The operator configured the secret. + Secret(String), + /// No secret was configured on a loopback address: this one is made for the run, and lost + /// when the process stops. + Ephemeral(String), + /// `SENSAPP_AUTH_DISABLED`: every endpoint is open. + Disabled, +} + +impl std::fmt::Debug for AuthMode { + // Never print a secret, even by mistake in a log + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + AuthMode::Secret(_) => "AuthMode::Secret(…)", + AuthMode::Ephemeral(_) => "AuthMode::Ephemeral(…)", + AuthMode::Disabled => "AuthMode::Disabled", + }) + } +} + +/// Make a random secret of 48 characters (288 bits), which `sensapp generate-secret` prints. +pub fn generate_secret() -> Result { + use base64::Engine; + let mut bytes = [0u8; 36]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow::anyhow!("Cannot get random bytes for a secret: {e}"))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +/// Decide how to authenticate. SensApp is not open unless asked to be: +/// +/// - a configured secret always wins, even when `auth_disabled` is also set; +/// - without a secret, `auth_disabled` opens everything, explicitly; +/// - without a secret on a loopback address (a developer's machine, where only the local user +/// can connect) a secret is made for this run; +/// - without a secret on any other address (a container, a server) it is an error: a secret made +/// per process would differ between instances, and silently open is what this prevents. +pub fn resolve_auth_mode( + secret: Option<&str>, + auth_disabled: bool, + endpoint: std::net::IpAddr, +) -> Result { + if let Some(secret) = secret { + return Ok(AuthMode::Secret(secret.to_string())); + } + if auth_disabled { + return Ok(AuthMode::Disabled); + } + if endpoint.is_loopback() { + return Ok(AuthMode::Ephemeral(generate_secret()?)); + } + anyhow::bail!( + "SensApp listens on {endpoint}, which other machines can reach, and has no \ + authentication configured. Choose one:\n \ + - set SENSAPP_JWT_SECRET to a secret of at least 32 characters (`sensapp generate-secret` makes one),\n \ + - listen on 127.0.0.1 for a local run, where SensApp makes a secret and prints an admin token,\n \ + - set SENSAPP_AUTH_DISABLED=true to run with every endpoint open." + ) +} + // --------------------------------------------------------------------------- // JWT Claims // --------------------------------------------------------------------------- @@ -86,12 +249,15 @@ impl AuthConfig { /// # Required fields /// - `sub`: Subject — identifies who the token was issued to /// - `exp`: Expiration time (Unix timestamp) — tokens without this are rejected +/// - `iss` and `aud`: both `sensapp` /// /// # Optional fields /// - `iat`: Issued-at time (informational) /// - `nbf`: Not-before time — if present, the token is rejected before this time -/// - `scope`: Space-separated scopes among `"read"`, `"write"` and `"delete"` (default: `"read write"`). -/// `"delete"` allows removing series and samples and is never part of the default. +/// - `jti`: Unique id of the token, in the logs +/// - `scope`: Space-separated scopes among `"read"`, `"write"`, `"delete"` and `"admin"` +/// (default: `"read write"`). `"delete"` allows removing series and samples and `"admin"` +/// allows making tokens; neither is ever part of the default. /// - `sensors`: Allow list of sensor names; if absent all sensors are accessible #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Claims { @@ -105,14 +271,201 @@ pub struct Claims { /// Not-before time (Unix timestamp). Validated when present. #[serde(skip_serializing_if = "Option::is_none")] pub nbf: Option, - /// Space-separated scopes among "read", "write" and "delete". - /// Defaults to "read write" if absent, which does not include "delete". + /// Space-separated scopes among "read", "write", "delete" and "admin". + /// Defaults to "read write" if absent, which includes neither "delete" nor "admin". #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, - /// Optional list of allowed sensor name patterns. + /// Optional list of allowed sensor names. /// If absent, all sensors are accessible. #[serde(skip_serializing_if = "Option::is_none")] pub sensors: Option>, + /// Issuer, `sensapp`. Required, and checked by the validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + /// Audience, `sensapp`. Required, and checked by the validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + /// Unique id of the token. + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, +} + +// --------------------------------------------------------------------------- +// Scopes +// --------------------------------------------------------------------------- + +/// What a token may do. Each one is explicit: none implies another. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + Read, + Write, + Delete, + /// Make tokens (`POST /api/v1/admin/tokens`), nothing else. + Admin, +} + +impl Scope { + pub fn name(self) -> &'static str { + match self { + Scope::Read => "read", + Scope::Write => "write", + Scope::Delete => "delete", + Scope::Admin => "admin", + } + } +} + +// --------------------------------------------------------------------------- +// Token requests +// --------------------------------------------------------------------------- + +/// Why a request for a token is refused. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TokenRequestError { + #[error("{0}")] + Invalid(String), + #[error("Admin tokens are only made with `sensapp generate-token`")] + AdminNotAllowed, +} + +/// What may be asked for, which depends on who asks: the command line, run with the secret, may +/// make anything, the HTTP endpoint does not make admin tokens and caps the duration. +#[derive(Clone, Copy, Debug)] +pub struct TokenRules { + pub max_duration_seconds: u64, + pub allow_admin: bool, +} + +const MAX_SUBJECT_LENGTH: usize = 128; +const MAX_SENSOR_NAME_LENGTH: usize = 256; +const MAX_SENSORS: usize = 1000; + +/// A request for a token, checked: the same rules for the command line and the HTTP endpoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TokenRequest { + pub subject: String, + /// Space-separated, in the form of the `scope` claim. + pub scope: String, + pub sensors: Option>, + pub duration_seconds: u64, +} + +impl TokenRequest { + /// Check a request. `scope` is a list of `read`, `write`, `delete`, `admin` and `readwrite` + /// (shorthand for `read write`), separated by commas or spaces. + pub fn new( + subject: &str, + scope: &str, + sensors: Option>, + duration_seconds: u64, + rules: &TokenRules, + ) -> Result { + let invalid = |message: String| TokenRequestError::Invalid(message); + + let subject = subject.trim(); + if subject.is_empty() { + return Err(invalid("The subject cannot be empty".into())); + } + if subject.chars().count() > MAX_SUBJECT_LENGTH { + return Err(invalid(format!( + "The subject is longer than {MAX_SUBJECT_LENGTH} characters" + ))); + } + + let scope = parse_scope(scope)?; + if !rules.allow_admin && scope.split(' ').any(|item| item == Scope::Admin.name()) { + return Err(TokenRequestError::AdminNotAllowed); + } + + if duration_seconds == 0 { + return Err(invalid("The duration must be at least one second".into())); + } + if duration_seconds > rules.max_duration_seconds { + return Err(invalid(format!( + "The duration cannot be longer than {} seconds", + rules.max_duration_seconds + ))); + } + + let sensors = match sensors { + None => None, + Some(names) => { + if names.is_empty() { + return Err(invalid( + "The list of sensors is empty: leave it out to allow every sensor".into(), + )); + } + if names.len() > MAX_SENSORS { + return Err(invalid(format!("More than {MAX_SENSORS} sensors"))); + } + // A name is kept exactly as given, spaces and commas included: it is matched + // exactly against the names of the sensors + let mut cleaned: Vec = Vec::with_capacity(names.len()); + for name in names { + if name.trim().is_empty() { + return Err(invalid("A sensor name is empty".into())); + } + if name.chars().count() > MAX_SENSOR_NAME_LENGTH { + return Err(invalid(format!( + "A sensor name is longer than {MAX_SENSOR_NAME_LENGTH} characters" + ))); + } + if !cleaned.contains(&name) { + cleaned.push(name); + } + } + Some(cleaned) + } + }; + + Ok(Self { + subject: subject.to_string(), + scope, + sensors, + duration_seconds, + }) + } + + /// Sign the request. + pub fn issue(&self, auth: &AuthConfig) -> Result { + auth.issue_token( + &self.subject, + &self.scope, + self.duration_seconds, + self.sensors.clone(), + ) + } +} + +/// Parse a list of scopes, comma or space separated: `read`, `write`, `delete`, `admin`, with +/// `readwrite` as a shorthand for `read write`. Returns them space-separated, each once. +pub fn parse_scope(raw: &str) -> Result { + let mut scopes: Vec<&str> = Vec::new(); + for item in raw.split([',', ' ']).filter(|item| !item.is_empty()) { + let expanded: &[&str] = match item { + "read" => &["read"], + "write" => &["write"], + "delete" => &["delete"], + "admin" => &["admin"], + "readwrite" => &["read", "write"], + other => { + return Err(TokenRequestError::Invalid(format!( + "Unknown scope: {other}. Use read, write, delete, admin, or readwrite" + ))); + } + }; + for scope in expanded { + if !scopes.contains(scope) { + scopes.push(scope); + } + } + } + if scopes.is_empty() { + return Err(TokenRequestError::Invalid( + "At least one scope is required".into(), + )); + } + Ok(scopes.join(" ")) } // --------------------------------------------------------------------------- @@ -123,9 +476,12 @@ pub struct Claims { #[derive(Clone, Debug)] pub struct AccessContext { pub subject: String, + /// Unique id of the token, when it has one. + pub token_id: Option, pub can_read: bool, pub can_write: bool, pub can_delete: bool, + pub can_admin: bool, pub sensor_allow_list: Option>, } @@ -140,43 +496,73 @@ impl AccessContext { None => true, } } + + pub fn has_scope(&self, scope: Scope) -> bool { + match scope { + Scope::Read => self.can_read, + Scope::Write => self.can_write, + Scope::Delete => self.can_delete, + Scope::Admin => self.can_admin, + } + } + + /// Put who is asking in the log span of the request, to tell who did what. + pub fn record_in_current_span(&self) { + let span = tracing::Span::current(); + span.record("subject", self.subject.as_str()); + if let Some(token_id) = &self.token_id { + span.record("token_id", token_id.as_str()); + } + } } // --------------------------------------------------------------------------- // Token validation // --------------------------------------------------------------------------- -/// Extract and validate a JWT from the `Authorization: Bearer ` header. +/// Extract and validate a JWT from the `Authorization: Bearer ` header. The +/// `Authorization: Token ` of the InfluxDB clients (Telegraf) is accepted as well. pub fn validate_token(headers: &HeaderMap, config: &AuthConfig) -> Result { let token = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) + .and_then(|v| { + v.strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("Token ")) + }) .ok_or(AppError::Unauthorized( "Missing or invalid Authorization header".into(), ))?; - let token_data = decode::(token, &config.decoding_key, &config.validation) + // The `kid` tells which secret signed the token. Without one it is the current secret's. + let token_header = decode_header(token) + .map_err(|e| AppError::Unauthorized(format!("Invalid token: {}", e)))?; + let key = match &token_header.kid { + Some(kid) => config.decoding_keys.get(kid).ok_or_else(|| { + AppError::Unauthorized("Invalid token: signed with an unknown secret".into()) + })?, + None => &config.decoding_key, + }; + + let token_data = decode::(token, key, &config.validation) .map_err(|e| AppError::Unauthorized(format!("Invalid token: {}", e)))?; let claims = token_data.claims; let scope = claims.scope.unwrap_or_else(|| "read write".to_string()); - let can_read = scope - .split_whitespace() - .any(|s| s.eq_ignore_ascii_case("read")); - let can_write = scope - .split_whitespace() - .any(|s| s.eq_ignore_ascii_case("write")); - let can_delete = scope - .split_whitespace() - .any(|s| s.eq_ignore_ascii_case("delete")); + let has = |wanted: Scope| { + scope + .split_whitespace() + .any(|s| s.eq_ignore_ascii_case(wanted.name())) + }; Ok(AccessContext { + can_read: has(Scope::Read), + can_write: has(Scope::Write), + can_delete: has(Scope::Delete), + can_admin: has(Scope::Admin), subject: claims.sub, - can_read, - can_write, - can_delete, + token_id: claims.jti, sensor_allow_list: claims.sensors, }) } @@ -185,59 +571,66 @@ pub fn validate_token(headers: &HeaderMap, config: &AuthConfig) -> Result>, +async fn require_scope( + auth: Option, mut request: Request, next: Next, + scope: Scope, ) -> Result { if let Some(config) = &auth { let access = validate_token(request.headers(), config)?; - if !access.can_read { - return Err(AppError::Forbidden("Read access required".into())); + if !access.has_scope(scope) { + return Err(AppError::Forbidden(format!( + "{} access required", + scope.name() + ))); } + access.record_in_current_span(); request.extensions_mut().insert(access); } Ok(next.run(request).await) } +/// Middleware that requires a valid JWT with **read** scope. +pub async fn require_read_auth( + State(auth): State>, + request: Request, + next: Next, +) -> Result { + require_scope(auth, request, next, Scope::Read).await +} + /// Middleware that requires a valid JWT with **write** scope. -/// -/// When `auth` is `None` (security disabled), all requests pass through. pub async fn require_write_auth( State(auth): State>, - mut request: Request, + request: Request, next: Next, ) -> Result { - if let Some(config) = &auth { - let access = validate_token(request.headers(), config)?; - if !access.can_write { - return Err(AppError::Forbidden("Write access required".into())); - } - request.extensions_mut().insert(access); - } - Ok(next.run(request).await) + require_scope(auth, request, next, Scope::Write).await } /// Middleware that requires a valid JWT with **delete** scope. /// /// The `delete` scope is never implied by the default `read write` scope. -/// When `auth` is `None` (security disabled), all requests pass through. pub async fn require_delete_auth( State(auth): State>, - mut request: Request, + request: Request, next: Next, ) -> Result { - if let Some(config) = &auth { - let access = validate_token(request.headers(), config)?; - if !access.can_delete { - return Err(AppError::Forbidden("Delete access required".into())); - } - request.extensions_mut().insert(access); - } - Ok(next.run(request).await) + require_scope(auth, request, next, Scope::Delete).await +} + +/// Middleware that requires a valid JWT with **admin** scope, to make tokens. The `admin` scope +/// gives neither read, write nor delete. +pub async fn require_admin_auth( + State(auth): State>, + request: Request, + next: Next, +) -> Result { + require_scope(auth, request, next, Scope::Admin).await } // --------------------------------------------------------------------------- @@ -264,6 +657,21 @@ mod tests { headers } + /// A valid base for the tests, which set what they test. + fn default_claims() -> Claims { + Claims { + sub: String::new(), + exp: 0, + iat: None, + nbf: None, + scope: None, + sensors: None, + iss: Some(TOKEN_ISSUER.to_string()), + aud: Some(TOKEN_AUDIENCE.to_string()), + jti: None, + } + } + fn encode_test_claims(claims: &Claims) -> String { encode( &Header::default(), @@ -294,6 +702,7 @@ mod tests { nbf: Some(1_000_000_000), scope: Some("read write".to_string()), sensors: None, + ..default_claims() }); let access = @@ -314,6 +723,7 @@ mod tests { nbf: None, scope: Some("read write".to_string()), sensors: None, + ..default_claims() }); let result = validate_token(&bearer_headers(&token), &make_config()); @@ -329,6 +739,7 @@ mod tests { nbf: Some(4_102_444_800), // far in the future scope: Some("read write".to_string()), sensors: None, + ..default_claims() }); let result = validate_token(&bearer_headers(&token), &make_config()); @@ -347,6 +758,7 @@ mod tests { nbf: None, scope: None, sensors: None, + ..default_claims() }, &wrong_key, ) @@ -382,6 +794,7 @@ mod tests { nbf: None, scope: Some("read".to_string()), sensors: None, + ..default_claims() }); let access = @@ -399,6 +812,7 @@ mod tests { nbf: None, scope: Some("write".to_string()), sensors: None, + ..default_claims() }); let access = @@ -416,6 +830,7 @@ mod tests { nbf: None, scope: None, sensors: None, + ..default_claims() }); let access = @@ -441,6 +856,7 @@ mod tests { nbf: None, scope: Some(scope.to_string()), sensors: None, + ..default_claims() }); let access = validate_token(&bearer_headers(&token), &make_config()).expect("should decode"); @@ -457,6 +873,7 @@ mod tests { nbf: None, scope: None, sensors: Some(vec!["temperature".to_string(), "humidity".to_string()]), + ..default_claims() }); let access = @@ -475,6 +892,7 @@ mod tests { nbf: None, scope: None, sensors: None, + ..default_claims() }); let access = @@ -487,8 +905,9 @@ mod tests { fn create_token_produces_valid_jwt() { let config = make_config(); let token = config - .create_token("my-service", "read write", 3600, None) - .expect("should create token"); + .issue_token("my-service", "read write", 3600, None) + .expect("should create token") + .token; let access = validate_token(&bearer_headers(&token), &config).expect("token should validate"); @@ -501,13 +920,14 @@ mod tests { fn create_token_with_sensor_filter() { let config = make_config(); let token = config - .create_token( + .issue_token( "sensor-reader", "read", 3600, Some(vec!["cpu".to_string(), "mem".to_string()]), ) - .expect("should create token"); + .expect("should create token") + .token; let access = validate_token(&bearer_headers(&token), &config).expect("token should validate"); @@ -518,4 +938,374 @@ mod tests { assert!(access.can_access_sensor("mem")); assert!(!access.can_access_sensor("disk")); } + + // -- token model -- + + fn config_with(secret: &str) -> AuthConfig { + AuthConfig::from_secret(secret).expect("config") + } + + const OLD_SECRET: &str = "old-secret-0123456789abcdef0123456789"; + + fn header_value(scheme: &str, token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("{scheme} {token}")).expect("header should build"), + ); + headers + } + + #[test] + fn issued_tokens_carry_an_id_and_a_key_id() { + let config = make_config(); + let first = config.issue_token("a", "read", 60, None).unwrap(); + let second = config.issue_token("a", "read", 60, None).unwrap(); + assert_ne!(first.jti, second.jti); + + let header = decode_header(&first.token).unwrap(); + assert_eq!(header.kid.as_deref(), Some(key_id(TEST_SECRET).as_str())); + // The key id is not the secret + assert!(!first.token.contains(TEST_SECRET)); + + let access = validate_token(&bearer_headers(&first.token), &config).unwrap(); + assert_eq!(access.token_id.as_deref(), Some(first.jti.as_str())); + } + + #[test] + fn the_token_scheme_of_influxdb_clients_is_accepted() { + let config = make_config(); + let token = config + .issue_token("telegraf", "write", 60, None) + .unwrap() + .token; + let access = validate_token(&header_value("Token", &token), &config).unwrap(); + assert_eq!(access.subject, "telegraf"); + assert!(access.can_write); + // Other schemes still are not + assert!(validate_token(&header_value("Basic", &token), &config).is_err()); + assert!(validate_token(&header_value("Token", "nonsense"), &config).is_err()); + } + + #[test] + fn rejects_another_issuer_or_audience() { + let mut claims = default_claims(); + claims.sub = "x".into(); + claims.exp = 4_102_444_800; + claims.scope = Some("read".into()); + + let mut other_issuer = claims.clone(); + other_issuer.iss = Some("another-system".into()); + assert!( + validate_token( + &bearer_headers(&encode_test_claims(&other_issuer)), + &make_config() + ) + .is_err() + ); + + let mut other_audience = claims.clone(); + other_audience.aud = Some("another-system".into()); + assert!( + validate_token( + &bearer_headers(&encode_test_claims(&other_audience)), + &make_config() + ) + .is_err() + ); + + let mut no_issuer = claims.clone(); + no_issuer.iss = None; + assert!( + validate_token( + &bearer_headers(&encode_test_claims(&no_issuer)), + &make_config() + ) + .is_err() + ); + + // The base is valid, so only the issuer or the audience made the others fail + assert!( + validate_token( + &bearer_headers(&encode_test_claims(&claims)), + &make_config() + ) + .is_ok() + ); + } + + #[test] + fn a_rotated_secret_still_verifies_the_tokens_of_the_previous_one() { + let old = config_with(OLD_SECRET); + let old_token = old.issue_token("client", "read", 60, None).unwrap().token; + + // Rotation: the new secret signs, the old one only verifies + let rotated = make_config() + .with_previous_secrets(&[OLD_SECRET.to_string()]) + .unwrap(); + assert!(validate_token(&bearer_headers(&old_token), &rotated).is_ok()); + let new_token = rotated + .issue_token("client", "read", 60, None) + .unwrap() + .token; + assert_eq!( + decode_header(&new_token).unwrap().kid.as_deref(), + Some(key_id(TEST_SECRET).as_str()) + ); + assert!(validate_token(&bearer_headers(&new_token), &rotated).is_ok()); + + // Once the old secret is dropped, its tokens are refused, and so is an unknown key id + let dropped = make_config(); + assert!(validate_token(&bearer_headers(&old_token), &dropped).is_err()); + assert!(validate_token(&bearer_headers(&new_token), &old).is_err()); + } + + #[test] + fn a_token_without_key_id_is_checked_against_the_current_secret() { + let mut claims = default_claims(); + claims.sub = "by-hand".into(); + claims.exp = 4_102_444_800; + let token = encode_test_claims(&claims); + assert!(decode_header(&token).unwrap().kid.is_none()); + assert!(validate_token(&bearer_headers(&token), &make_config()).is_ok()); + + // ...and not against a previous one + let rotated = config_with(OLD_SECRET) + .with_previous_secrets(&[TEST_SECRET.to_string()]) + .unwrap(); + assert!(validate_token(&bearer_headers(&token), &rotated).is_err()); + } + + #[test] + fn previous_secrets_must_be_long_enough() { + let result = make_config().with_previous_secrets(&["short".to_string()]); + assert!(result.is_err()); + } + + #[test] + fn admin_is_a_scope_of_its_own() { + let config = make_config(); + let admin = config.issue_token("root", "admin", 60, None).unwrap().token; + let access = validate_token(&bearer_headers(&admin), &config).unwrap(); + assert!(access.can_admin); + assert!(!access.can_read && !access.can_write && !access.can_delete); + + for scope in ["read write", "read write delete", "readwrite"] { + let token = config.issue_token("user", scope, 60, None).unwrap().token; + assert!( + !validate_token(&bearer_headers(&token), &config) + .unwrap() + .can_admin + ); + } + // No scope claim means the default, without admin + let mut claims = default_claims(); + claims.sub = "x".into(); + claims.exp = 4_102_444_800; + let access = + validate_token(&bearer_headers(&encode_test_claims(&claims)), &config).unwrap(); + assert!(!access.can_admin); + } + + // -- token requests -- + + const CLI_RULES: TokenRules = TokenRules { + max_duration_seconds: 10 * 365 * 24 * 3600, + allow_admin: true, + }; + const ENDPOINT_RULES: TokenRules = TokenRules { + max_duration_seconds: 365 * 24 * 3600, + allow_admin: false, + }; + + #[test] + fn parses_scope_combinations() { + assert_eq!(parse_scope("read").unwrap(), "read"); + assert_eq!(parse_scope("readwrite").unwrap(), "read write"); + assert_eq!(parse_scope("read write").unwrap(), "read write"); + assert_eq!(parse_scope("read,write").unwrap(), "read write"); + assert_eq!(parse_scope("delete").unwrap(), "delete"); + assert_eq!(parse_scope("admin").unwrap(), "admin"); + assert_eq!( + parse_scope("readwrite,delete").unwrap(), + "read write delete" + ); + assert_eq!(parse_scope("read,read").unwrap(), "read"); + } + + #[test] + fn rejects_unknown_or_empty_scopes() { + assert!(parse_scope("root").is_err()); + assert!(parse_scope("read,root").is_err()); + assert!(parse_scope("").is_err()); + assert!(parse_scope(" , ").is_err()); + } + + #[test] + fn a_valid_request_is_cleaned() { + let request = TokenRequest::new( + " edge-7 ", + "readwrite", + Some(vec!["temp".into(), "humidity".into(), "temp".into()]), + 3600, + &ENDPOINT_RULES, + ) + .unwrap(); + assert_eq!(request.subject, "edge-7"); + assert_eq!(request.scope, "read write"); + assert_eq!( + request.sensors, + Some(vec!["temp".to_string(), "humidity".to_string()]) + ); + assert_eq!(request.duration_seconds, 3600); + } + + #[test] + fn sensor_names_are_kept_exactly_commas_and_spaces_included() { + let names = vec![ + "cpu,usage".to_string(), + " padded ".to_string(), + "cpu usage_idle".to_string(), + ]; + let request = + TokenRequest::new("a", "read", Some(names.clone()), 60, &ENDPOINT_RULES).unwrap(); + assert_eq!(request.sensors, Some(names)); + + // ...and the token matches them exactly + let config = make_config(); + let issued = request.issue(&config).unwrap(); + let access = validate_token(&bearer_headers(&issued.token), &config).unwrap(); + assert!(access.can_access_sensor("cpu,usage")); + assert!(access.can_access_sensor(" padded ")); + assert!(!access.can_access_sensor("padded")); + assert!(!access.can_access_sensor("cpu")); + assert!(!access.can_access_sensor("usage")); + } + + #[test] + fn requests_are_checked() { + let ask = |subject: &str, scope: &str, sensors: Option>, duration: u64| { + TokenRequest::new(subject, scope, sensors, duration, &ENDPOINT_RULES) + }; + assert!(ask("", "read", None, 60).is_err()); + assert!(ask(" ", "read", None, 60).is_err()); + assert!(ask(&"x".repeat(129), "read", None, 60).is_err()); + assert!(ask(&"x".repeat(128), "read", None, 60).is_ok()); + assert!(ask("a", "read", None, 0).is_err()); + assert!(ask("a", "read", None, ENDPOINT_RULES.max_duration_seconds).is_ok()); + assert!(ask("a", "read", None, ENDPOINT_RULES.max_duration_seconds + 1).is_err()); + assert!(ask("a", "read", Some(vec![]), 60).is_err()); + assert!(ask("a", "read", Some(vec!["".into()]), 60).is_err()); + assert!(ask("a", "read", Some(vec![" ".into()]), 60).is_err()); + assert!(ask("a", "read", Some(vec!["x".repeat(257)]), 60).is_err()); + assert!(ask("a", "read", Some(vec!["s".into(); 1001]), 60).is_err()); + } + + #[test] + fn only_the_command_line_makes_admin_tokens() { + assert_eq!( + TokenRequest::new("a", "read,admin", None, 60, &ENDPOINT_RULES), + Err(TokenRequestError::AdminNotAllowed) + ); + assert!(TokenRequest::new("a", "admin", None, 60, &CLI_RULES).is_ok()); + } + + #[test] + fn a_request_is_signed_as_asked() { + let config = make_config(); + let request = TokenRequest::new( + "edge-7", + "write", + Some(vec!["temp".into()]), + 120, + &CLI_RULES, + ) + .unwrap(); + let issued = request.issue(&config).unwrap(); + let access = validate_token(&bearer_headers(&issued.token), &config).unwrap(); + assert_eq!(access.subject, "edge-7"); + assert!(access.can_write && !access.can_read); + assert!(access.can_access_sensor("temp") && !access.can_access_sensor("other")); + } + + #[test] + fn a_huge_duration_does_not_overflow() { + assert!( + make_config() + .issue_token("a", "read", u64::MAX, None) + .is_err() + ); + } + + // -- startup mode -- + + const LOOPBACK: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST); + const EXPOSED: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED); + + #[test] + fn a_configured_secret_wins_everywhere() { + for endpoint in [LOOPBACK, EXPOSED] { + for disabled in [false, true] { + let mode = resolve_auth_mode(Some(TEST_SECRET), disabled, endpoint).unwrap(); + assert_eq!(mode, AuthMode::Secret(TEST_SECRET.to_string())); + } + } + } + + #[test] + fn disabling_authentication_is_an_explicit_choice() { + for endpoint in [LOOPBACK, EXPOSED] { + let mode = resolve_auth_mode(None, true, endpoint).unwrap(); + assert_eq!(mode, AuthMode::Disabled); + } + } + + #[test] + fn loopback_without_secret_makes_one_for_the_run() { + for endpoint in [ + LOOPBACK, + std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), + ] { + let AuthMode::Ephemeral(secret) = resolve_auth_mode(None, false, endpoint).unwrap() + else { + panic!("expected an ephemeral secret on {endpoint}"); + }; + // It is usable as a signing secret + AuthConfig::from_secret(&secret).expect("the made secret is accepted"); + } + } + + #[test] + fn exposed_address_without_secret_refuses_to_start() { + for endpoint in [ + EXPOSED, + "192.168.1.10".parse().unwrap(), + "::".parse().unwrap(), + ] { + let error = resolve_auth_mode(None, false, endpoint).expect_err("must refuse"); + let message = error.to_string(); + assert!(message.contains("SENSAPP_JWT_SECRET"), "{message}"); + assert!(message.contains("SENSAPP_AUTH_DISABLED"), "{message}"); + assert!(message.contains("generate-secret"), "{message}"); + } + } + + #[test] + fn generated_secrets_are_long_and_different() { + let first = generate_secret().unwrap(); + let second = generate_secret().unwrap(); + assert_eq!(first.len(), 48); + assert_ne!(first, second); + assert!( + first + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + ); + } + + #[test] + fn the_mode_never_prints_its_secret() { + let mode = AuthMode::Secret(TEST_SECRET.to_string()); + assert!(!format!("{mode:?}").contains(TEST_SECRET)); + } } diff --git a/src/http/crud.rs b/src/http/crud.rs index 23701d9c..8254c594 100644 --- a/src/http/crud.rs +++ b/src/http/crud.rs @@ -374,6 +374,7 @@ fn last_sample_json(samples: &crate::datamodel::TypedSamples) -> Option<(String, #[utoipa::path( get, path = "/metrics", + security(("bearer" = ["read"])), tag = "SensApp", params( ("name" = Option, Query, description = "Filter metrics by name (substring match)"), @@ -620,13 +621,16 @@ async fn list_filtered_series( #[utoipa::path( get, path = "/series", + security(("bearer" = ["read"])), tag = "SensApp", params( ("metric" = Option, Query, description = "Filter series by metric name"), - ("selector" = Option, Query, description = "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')") + ("selector" = Option, Query, description = "PromQL-style label selector (e.g., '{env=\"prod\",region=~\"us.*\"}')"), + ("limit" = Option, Query, description = "Series per page, 256 by default, 16384 at most"), + ("bookmark" = Option, Query, description = "Cursor of the next page: the `bookmark` of the `hydra:next` link of the previous page") ), responses( - (status = 200, description = "Time series catalog in DCAT format", body = Value), + (status = 200, description = "Time series catalog in DCAT format. A full page has a `hydra:view` with the link to the next page", body = Value), (status = 503, description = "The storage backend is unavailable: retry later"), (status = 504, description = "The request took longer than SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS, usually because the storage backend hangs") ) @@ -827,6 +831,7 @@ pub async fn list_series( #[utoipa::path( get, path = "/series/{series_uuid}", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1036,6 +1041,7 @@ pub async fn get_series_data( #[utoipa::path( get, path = "/series/{series_uuid}/last", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1118,6 +1124,7 @@ pub async fn get_series_last_sample( #[utoipa::path( delete, path = "/series/{series_uuid}", + security(("bearer" = ["delete"])), tag = "Admin", params( ("series_uuid" = String, Path, description = "UUID of the series to delete") @@ -1186,6 +1193,7 @@ pub async fn delete_series( #[utoipa::path( delete, path = "/series/{series_uuid}/samples", + security(("bearer" = ["delete"])), tag = "Admin", params( ("series_uuid" = String, Path, description = "UUID of the series"), @@ -1275,6 +1283,7 @@ pub async fn delete_series_samples( #[utoipa::path( get, path = "/series/{series_uuid}/availability", + security(("bearer" = ["read"])), tag = "SensApp", params( ("series_uuid" = String, Path, description = "UUID of the series"), diff --git a/src/http/influxdb.rs b/src/http/influxdb.rs index fe6995d6..d1ab6c08 100644 --- a/src/http/influxdb.rs +++ b/src/http/influxdb.rs @@ -164,6 +164,7 @@ impl FromStr for Precision { #[utoipa::path( post, path = "/api/v2/write", + security(("bearer" = ["write"])), tag = "InfluxDB", request_body( content = String, diff --git a/src/http/metrics.rs b/src/http/metrics.rs index 47687e60..06c05ae7 100644 --- a/src/http/metrics.rs +++ b/src/http/metrics.rs @@ -297,6 +297,8 @@ impl HttpMetrics { #[utoipa::path( get, path = "/prometheus/metrics", + // Public, except with `include_latest_samples=true`, which needs a read token + security((), ("bearer" = ["read"])), tag = "Observability", params( ("include_latest_samples" = Option, Query, description = "Append the most recent sample for Prometheus-compatible series"), diff --git a/src/http/mod.rs b/src/http/mod.rs index d0239e8a..c4ffbd01 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -12,3 +12,4 @@ pub mod prometheus_write; pub mod server; pub mod simple_promql; pub mod state; +pub mod ui; diff --git a/src/http/prometheus_read.rs b/src/http/prometheus_read.rs index 8b38f26b..aeb13ac0 100644 --- a/src/http/prometheus_read.rs +++ b/src/http/prometheus_read.rs @@ -24,24 +24,34 @@ use std::time::Instant; use tokio_util::bytes::Bytes; use tracing::{debug, info, warn}; +/// Prometheus does not trust a remote read to have answered the function of the hint: it +/// evaluates the query again on the samples it gets back. A hint is therefore only answered with +/// buckets when the second evaluation gives the result of the raw samples, which is the case of +/// the `*_over_time` functions that can be merged (min, max, sum, first, last, and the average +/// of buckets, exact when the buckets hold the same number of samples). `count_over_time` would +/// count the buckets, and the aggregation operators (`sum`, `avg`, ...) ask for the value of each +/// series at the evaluation time, not for an aggregate of the hour that follows it. fn aggregation_from_read_hints(hints: &ReadHints) -> Option { - match hints.func.trim().to_ascii_lowercase().as_str() { - "avg_over_time" | "avg" => Some(Aggregation::Avg), - "min_over_time" | "min" => Some(Aggregation::Min), - "max_over_time" | "max" => Some(Aggregation::Max), - "sum_over_time" | "sum" => Some(Aggregation::Sum), - "count_over_time" | "count" => Some(Aggregation::Count), - "first_over_time" | "first" => Some(Aggregation::First), - "last_over_time" | "last" => Some(Aggregation::Last), + match hints.func.trim() { + "avg_over_time" => Some(Aggregation::Avg), + "min_over_time" => Some(Aggregation::Min), + "max_over_time" => Some(Aggregation::Max), + "sum_over_time" => Some(Aggregation::Sum), + "first_over_time" => Some(Aggregation::First), + "last_over_time" => Some(Aggregation::Last), _ => None, } } +/// `range_ms` is the width of the `[1h]` of the query, `step_ms` the distance between two +/// evaluations. Prometheus starts the window at the end of the first range, so buckets of one +/// step that start there fit the windows of the evaluations when the range is a whole number of +/// steps. A shorter range would be answered with a bucket wider than the window it asks for. fn query_options_from_read_hints(query: &Query) -> Option { let hints = query.hints.as_ref()?; let aggregation = aggregation_from_read_hints(hints)?; - if hints.step_ms <= 0 { + if hints.step_ms <= 0 || hints.range_ms <= 0 || hints.range_ms % hints.step_ms != 0 { return None; } @@ -177,6 +187,7 @@ fn verify_read_headers(headers: &HeaderMap) -> Result<(), AppError> { #[utoipa::path( post, path = "/api/v1/prometheus_remote_read", + security(("bearer" = ["read"])), tag = "Prometheus", request_body( content_type = "application/x-protobuf", @@ -601,60 +612,102 @@ mod tests { assert!(verify_read_headers(&headers).is_err()); } - #[test] - fn test_aggregation_from_read_hints() { - let hints = ReadHints { - step_ms: 60_000, - func: "avg_over_time".to_string(), + fn hints(func: &str, step_ms: i64, range_ms: i64) -> ReadHints { + ReadHints { + step_ms, + func: func.to_string(), start_ms: 0, end_ms: 0, grouping: vec![], by: false, - range_ms: 60_000, - }; - - assert_eq!(aggregation_from_read_hints(&hints), Some(Aggregation::Avg)); - - let unknown = ReadHints { - func: "rate".to_string(), - ..hints - }; - assert_eq!(aggregation_from_read_hints(&unknown), None); + range_ms, + } } - #[test] - fn test_query_options_from_read_hints_requires_supported_func() { - let query = Query { + fn query_with(hints: ReadHints) -> Query { + Query { start_timestamp_ms: 1_000, end_timestamp_ms: 5_000, matchers: vec![], - hints: Some(ReadHints { - step_ms: 2_000, - func: "max_over_time".to_string(), - start_ms: 1_000, - end_ms: 5_000, - grouping: vec![], - by: false, - range_ms: 2_000, - }), - }; + hints: Some(hints), + } + } + + #[test] + fn test_aggregation_from_read_hints() { + for (func, expected) in [ + ("avg_over_time", Aggregation::Avg), + ("min_over_time", Aggregation::Min), + ("max_over_time", Aggregation::Max), + ("sum_over_time", Aggregation::Sum), + ("first_over_time", Aggregation::First), + ("last_over_time", Aggregation::Last), + ] { + assert_eq!( + aggregation_from_read_hints(&hints(func, 60_000, 60_000)), + Some(expected), + "{func}" + ); + } + } + + #[test] + fn test_hints_that_buckets_would_answer_wrongly_are_not_aggregated() { + // Prometheus counts the buckets, evaluates the aggregation operators at the evaluation + // time and cannot use buckets for a rate. + for func in [ + "count_over_time", + "count", + "sum", + "avg", + "min", + "max", + "rate", + "", + ] { + assert_eq!( + aggregation_from_read_hints(&hints(func, 60_000, 60_000)), + None, + "{func:?}" + ); + } + } - let options = query_options_from_read_hints(&query).unwrap(); + #[test] + fn test_query_options_from_read_hints_follow_the_range_and_the_step() { + let options = + query_options_from_read_hints(&query_with(hints("max_over_time", 2_000, 2_000))) + .unwrap(); assert_eq!(options.step_ms, Some(2_000)); assert_eq!(options.aggregation, Some(Aggregation::Max)); - let unsupported = Query { - hints: Some(ReadHints { - step_ms: 2_000, - func: "rate".to_string(), - start_ms: 1_000, - end_ms: 5_000, - grouping: vec![], - by: false, - range_ms: 2_000, - }), - ..query - }; - assert!(query_options_from_read_hints(&unsupported).is_none()); + // Several steps in a range keep the buckets inside the windows. + assert!( + query_options_from_read_hints(&query_with(hints("max_over_time", 2_000, 6_000))) + .is_some() + ); + + // A range shorter than the step, a range that is not a whole number of steps, an instant + // selector (no range) or no step cannot be answered with buckets. + for (step_ms, range_ms) in [(2_000, 1_000), (2_000, 3_000), (2_000, 0), (0, 2_000)] { + assert!( + query_options_from_read_hints(&query_with(hints( + "max_over_time", + step_ms, + range_ms + ))) + .is_none(), + "step {step_ms} range {range_ms}" + ); + } + + assert!(query_options_from_read_hints(&query_with(hints("rate", 2_000, 2_000))).is_none()); + assert!( + query_options_from_read_hints(&Query { + hints: None, + ..query_with(hints("max_over_time", 2_000, 2_000)) + }) + .is_none() + ); } } diff --git a/src/http/prometheus_write.rs b/src/http/prometheus_write.rs index 931d98ec..6d889f15 100644 --- a/src/http/prometheus_write.rs +++ b/src/http/prometheus_write.rs @@ -88,6 +88,7 @@ fn verify_headers(headers: &HeaderMap) -> Result<(), AppError> { #[utoipa::path( post, path = "/api/v1/prometheus_remote_write", + security(("bearer" = ["write"])), tag = "Prometheus", request_body( content_type = "application/x-protobuf", diff --git a/src/http/server.rs b/src/http/server.rs index 8a23c9ad..1c0bb6d1 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -1,5 +1,8 @@ use super::app_error::AppError; -use super::auth::{require_delete_auth, require_read_auth, require_write_auth}; +use super::auth::{ + TokenRequest, TokenRequestError, TokenRules, require_admin_auth, require_delete_auth, + require_read_auth, require_write_auth, +}; use super::backpressure::{WriteLimiter, limit_concurrent_writes}; use super::crud::{ delete_series, delete_series_samples, get_series_availability, get_series_data, @@ -11,6 +14,7 @@ use super::prometheus_read::prometheus_remote_read; use super::prometheus_write::publish_prometheus; use super::simple_promql::simple_promql_query; use super::state::HttpServerState; +use super::ui::{add_ui, ui_directory}; use crate::config; use crate::http::crud::{ __path_delete_series, __path_delete_series_samples, __path_get_series_availability, @@ -40,9 +44,10 @@ use axum::routing::delete; use axum::routing::get; use axum::routing::post; use futures::TryStreamExt; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::io; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tower::ServiceBuilder; @@ -54,8 +59,36 @@ use tracing::Level; use utoipa::{OpenApi, ToSchema}; use utoipa_scalar::{Scalar, Servable as ScalarServable}; +/// Declares the bearer token every protected operation refers to (`security` of each path). +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "A JWT signed with the secret of the server, sent as `Authorization: Bearer ` \ + (`Authorization: Token `, which InfluxDB clients send, works too). Each operation \ + lists the scope it needs: `read`, `write`, `delete` or `admin`. Make one with \ + `sensapp generate-token`, or with `POST /api/v1/admin/tokens` and an `admin` token \ + (the Credentials tab of the web UI does). With authentication disabled \ + (`SENSAPP_AUTH_DISABLED`) no token is needed.", + )) + .build(), + ), + ); + } +} + #[derive(OpenApi)] #[openapi( + modifiers(&SecurityAddon), tags( (name = "SensApp", description = "SensApp API"), (name = "InfluxDB", description = "InfluxDB Write API"), @@ -64,7 +97,7 @@ use utoipa_scalar::{Scalar, Servable as ScalarServable}; (name = "Admin", description = "Administrative operations"), (name = "Health", description = "Health check endpoints"), ), - paths(frontpage, publish_sensors_data, prometheus_metrics, list_metrics, list_series, get_series_data, get_series_last_sample, get_series_availability, delete_series, delete_series_samples, publish_influxdb, publish_prometheus, prometheus_remote_read, simple_promql_query, vacuum_database, liveness, readiness), + paths(frontpage, publish_sensors_data, prometheus_metrics, list_metrics, list_series, get_series_data, get_series_last_sample, get_series_availability, delete_series, delete_series_samples, publish_influxdb, publish_prometheus, prometheus_remote_read, simple_promql_query, vacuum_database, create_token, liveness, readiness), )] struct ApiDoc; @@ -82,6 +115,8 @@ pub struct RouterSettings { pub maintenance_timeout: Duration, /// Write requests handled at the same time, 0 for no limit pub max_concurrent_writes: usize, + /// Directory of the built web UI to serve under `/ui/`, `None` to not serve it + pub ui_dir: Option, } impl RouterSettings { @@ -92,6 +127,7 @@ impl RouterSettings { write_timeout: Duration::from_secs(config.http_write_timeout_seconds), maintenance_timeout: Duration::from_secs(config.http_maintenance_timeout_seconds), max_concurrent_writes: config.http_max_concurrent_writes, + ui_dir: ui_directory(config), }) } } @@ -141,6 +177,9 @@ pub fn build_router(state: HttpServerState, settings: &RouterSettings) -> Router uri = %request.uri(), version = ?request.version(), request_id = %request_id, + // Filled by the authentication, to tell who did what + subject = tracing::field::Empty, + token_id = tracing::field::Empty, ) }) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)) @@ -152,13 +191,17 @@ pub fn build_router(state: HttpServerState, settings: &RouterSettings) -> Router // Create our application with route groups split by auth requirements. // - // Public routes — always accessible (health checks, docs, prometheus scrape): + // Public routes — always accessible (health checks, docs, prometheus scrape, the UI files): let public_routes = Router::new() - .route("/", get(frontpage)) .merge(Scalar::with_url("/docs", ApiDoc::openapi())) .route("/prometheus/metrics", get(prometheus_metrics)) .route("/health/live", get(liveness)) .route("/health/ready", get(readiness)); + // `/` redirects to the UI when it is served, and is the name of the instance otherwise + let public_routes = match &settings.ui_dir { + Some(directory) => add_ui(public_routes, directory), + None => public_routes.route("/", get(frontpage)), + }; // Read-protected routes — require a valid JWT with "read" scope when auth is enabled: let read_routes = Router::new() @@ -236,11 +279,20 @@ pub fn build_router(state: HttpServerState, settings: &RouterSettings) -> Router )) .layer(timeout_layer(settings.maintenance_timeout)); + // Making tokens needs the admin scope, which gives nothing else + let admin_routes = Router::new() + .route("/api/v1/admin/tokens", post(create_token)) + .route_layer(axum::middleware::from_fn_with_state( + state.auth.clone(), + require_admin_auth, + )); + // The writes and the maintenance have timeouts of their own, merged after the layer of the // other requests so that this one does not apply to them. public_routes .merge(read_routes) .merge(delete_routes) + .merge(admin_routes) .layer(timeout_layer(settings.request_timeout)) .merge(write_routes) .merge(maintenance_routes) @@ -330,7 +382,7 @@ async fn enforce_request_body_limit( path = "/", tag = "SensApp", responses( - (status = 200, description = "SensApp Frontpage", body = String) + (status = 200, description = "SensApp Frontpage: the name of the instance. Redirects to the web UI at `/ui/` instead when the UI is served", body = String) ) )] async fn frontpage(State(state): State) -> Result, AppError> { @@ -349,6 +401,7 @@ async fn frontpage(State(state): State) -> Result, #[utoipa::path( post, path = "/publish", + security(("bearer" = ["write"])), tag = "SensApp", request_body( content = String, @@ -514,6 +567,118 @@ struct VacuumResponse { duplicates_removed: Option, } +/// What to put in a token: who it is for, what it may do, for how long. +#[derive(Debug, Deserialize, ToSchema)] +struct CreateTokenRequest { + /// Who or what the token is for: a service, a device, a person. It is in the logs of every + /// request the token makes. + #[schema(example = "edge-device-7")] + subject: String, + /// What the token may do, among `read`, `write` and `delete`. `admin` tokens are only made with + /// `sensapp generate-token`. + #[schema(example = json!(["read", "write"]))] + scope: Vec, + /// Names of the sensors the token may access. Leave it out for every sensor. + #[schema(example = json!(["temperature", "humidity"]))] + sensors: Option>, + /// Validity of the token, in seconds, at most `SENSAPP_TOKEN_MAX_DURATION_SECONDS` (a year by + /// default). + #[schema(example = 86400)] + duration_seconds: u64, +} + +#[derive(Debug, Serialize, ToSchema)] +struct CreatedToken { + /// The token, to use as `Authorization: Bearer `. This is the only time it is shown: + /// SensApp does not keep tokens. + token: String, + subject: String, + scope: Vec, + sensors: Option>, + /// Expiration time, Unix timestamp in seconds + expires_at: u64, + /// Unique id of the token, in the logs of the requests it makes + jti: String, +} + +/// Create a token +/// +/// Makes a signed token for a client. SensApp does not keep tokens: the answer is the only time +/// the token is shown, it cannot be listed or revoked, and it is valid until it expires or until +/// the secret that signed it is rotated out (see `SENSAPP_JWT_PREVIOUS_SECRETS`). Requires the +/// `admin` scope. An `admin` token cannot make another `admin` token: those come from +/// `sensapp generate-token`, which needs the secret. Not available when authentication is disabled. +#[utoipa::path( + post, + path = "/api/v1/admin/tokens", + security(("bearer" = ["admin"])), + tag = "Admin", + request_body = CreateTokenRequest, + responses( + (status = 200, description = "The token", body = CreatedToken), + (status = 400, description = "The request is not valid: empty subject, unknown scope, too long a duration"), + (status = 401, description = "Missing or invalid token"), + (status = 403, description = "The token does not have the admin scope, or the request asks for an admin token"), + (status = 404, description = "Authentication is disabled: there is nothing to sign a token with") + ) +)] +async fn create_token( + State(state): State, + access: Option>, + Json(request): Json, +) -> Result<([(header::HeaderName, &'static str); 1], Json), AppError> { + let Some(auth) = &state.auth else { + return Err(AppError::NotFound(anyhow::anyhow!( + "Authentication is disabled: there is nothing to sign a token with" + ))); + }; + + let rules = TokenRules { + max_duration_seconds: auth.max_token_duration_seconds(), + allow_admin: false, + }; + let checked = TokenRequest::new( + &request.subject, + &request.scope.join(" "), + request.sensors, + request.duration_seconds, + &rules, + ) + .map_err(|error| match error { + TokenRequestError::AdminNotAllowed => AppError::Forbidden(error.to_string()), + TokenRequestError::Invalid(message) => AppError::BadRequest(anyhow::anyhow!(message)), + })?; + let issued = checked.issue(auth).map_err(AppError::InternalServerError)?; + + // Who made which token, for the audit. Never the token itself. + let creator = access + .as_ref() + .map(|extension| extension.0.subject.as_str()); + tracing::info!( + creator, + subject = %checked.subject, + scope = %checked.scope, + sensors = ?checked.sensors, + expires_at = issued.expires_at, + jti = %issued.jti, + "token created" + ); + + let scope = checked.scope.split(' ').map(str::to_string).collect(); + Ok(( + // A token must not stay in a cache + [(header::CACHE_CONTROL, "no-store")], + Json(CreatedToken { + token: issued.token, + subject: checked.subject, + scope, + sensors: checked.sensors, + expires_at: issued.expires_at, + jti: issued.jti, + }), + )) +} + /// Database Vacuuming /// /// Removes the duplicate samples (a retried write, a client that sends twice, a crash in the middle @@ -523,6 +688,7 @@ struct VacuumResponse { #[utoipa::path( post, path = "/api/v1/admin/vacuum", + security(("bearer" = ["delete"])), tag = "Admin", responses( (status = 200, description = "Maintenance completed, with the number of duplicate samples removed", body = VacuumResponse), @@ -589,6 +755,31 @@ mod tests { } } + /// The client of the frontend is generated from `frontend/openapi.json`, so that file is the + /// document of this server. After an API change, rewrite it and regenerate the client: + /// `UPDATE_OPENAPI=1 cargo test frontend_openapi_document`, then `npm run openapi-ts` in `frontend/`. + #[test] + fn frontend_openapi_document_is_up_to_date() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/frontend/openapi.json"); + let current = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); + + if std::env::var_os("UPDATE_OPENAPI").is_some() { + let json = serde_json::to_string_pretty(¤t).expect("format OpenAPI document"); + std::fs::write(path, json + "\n").expect("write frontend/openapi.json"); + return; + } + + let saved: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).expect("read frontend/openapi.json"), + ) + .expect("parse frontend/openapi.json"); + assert!( + saved == current, + "frontend/openapi.json is not the OpenAPI document of the server: run \ + `UPDATE_OPENAPI=1 cargo test frontend_openapi_document`, then `npm run openapi-ts` in frontend/" + ); + } + #[test] fn data_endpoints_document_overload_and_timeout_answers() { let document = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); @@ -599,6 +790,8 @@ mod tests { "/health/live", "/health/ready", "/docs", + // Signs a token, never touches the storage + "/api/v1/admin/tokens", ]; let writes = [ ("/publish", "post"), @@ -632,6 +825,68 @@ mod tests { } } + #[test] + fn protected_operations_declare_the_scope_they_need() { + let document = serde_json::to_value(ApiDoc::openapi()).expect("serialize OpenAPI document"); + assert_eq!( + document["components"]["securitySchemes"]["bearer"]["scheme"], + "bearer" + ); + + let public = ["/", "/health/live", "/health/ready", "/docs"]; + for (path, operations) in document["paths"].as_object().expect("OpenAPI paths") { + for (method, operation) in operations.as_object().expect("operations") { + let security = operation.get("security"); + if public.contains(&path.as_str()) { + assert!(security.is_none(), "{method} {path} is public"); + continue; + } + let requirements = security + .and_then(|security| security.as_array()) + .unwrap_or_else(|| panic!("{method} {path} does not declare its scope")); + // A token with one scope, or (the service metrics) no token at all + let scopes: Vec<&str> = requirements + .iter() + .filter_map(|requirement| requirement.get("bearer")) + .flat_map(|scopes| scopes.as_array().unwrap()) + .filter_map(|scope| scope.as_str()) + .collect(); + assert_eq!(scopes.len(), 1, "{method} {path}: {requirements:?}"); + assert!( + ["read", "write", "delete", "admin"].contains(&scopes[0]), + "{method} {path}: {scopes:?}" + ); + } + } + + let scope_of = |path: &str, method: &str| -> String { + document["paths"][path][method]["security"] + .as_array() + .and_then(|requirements| { + requirements + .iter() + .find_map(|requirement| requirement["bearer"][0].as_str()) + }) + .unwrap_or_default() + .to_string() + }; + assert_eq!(scope_of("/publish", "post"), "write"); + assert_eq!(scope_of("/api/v2/write", "post"), "write"); + assert_eq!(scope_of("/series/{series_uuid}", "get"), "read"); + assert_eq!(scope_of("/series/{series_uuid}", "delete"), "delete"); + assert_eq!(scope_of("/api/v1/admin/vacuum", "post"), "delete"); + assert_eq!(scope_of("/api/v1/admin/tokens", "post"), "admin"); + // Public, but a token with `read` gets the latest samples + let metrics = &document["paths"]["/prometheus/metrics"]["get"]["security"]; + assert!( + metrics + .as_array() + .unwrap() + .iter() + .any(|requirement| requirement.as_object().unwrap().is_empty()) + ); + } + /// Helper to get test database URL - uses the centralized constant from test_utils fn get_test_database_url() -> String { sensapp::test_utils::get_test_database_url() diff --git a/src/http/simple_promql.rs b/src/http/simple_promql.rs index 7ca028b2..addbc5fb 100644 --- a/src/http/simple_promql.rs +++ b/src/http/simple_promql.rs @@ -242,6 +242,7 @@ fn parse_promql_query(query: &str) -> Result { #[utoipa::path( get, path = "/api/v1/query", + security(("bearer" = ["read"])), tag = "SensApp", params( ("query" = String, Query, description = "PromQL query string (e.g., 'my_metric{label=\"value\"}' or 'my_metric[5m]')"), diff --git a/src/http/ui.rs b/src/http/ui.rs new file mode 100644 index 00000000..fcee5614 --- /dev/null +++ b/src/http/ui.rs @@ -0,0 +1,195 @@ +//! The web UI: the static files built from `frontend/`, served under `/ui/`. +//! +//! The files are public, like `/docs`: they hold no data. The API calls the UI makes are protected +//! by the same JWT authentication as any other client's. + +use crate::config::SensAppConfig; +use axum::Router; +use axum::http::{HeaderValue, header}; +use axum::response::Redirect; +use axum::routing::get; +use std::path::{Path, PathBuf}; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; + +/// Where the UI lives. The frontend is built for this base path (`base` in `vite.config.ts`). +pub const UI_PATH: &str = "/ui"; + +/// The UI holds a token in the browser, so it only talks to its own origin, and nothing may frame it. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"; + +/// The directory to serve the UI from, or `None` when the UI is turned off or was not built. +pub fn ui_directory(config: &SensAppConfig) -> Option { + if !config.ui_enabled { + return None; + } + let directory = PathBuf::from(&config.ui_dir); + if directory.join("index.html").is_file() { + tracing::info!(directory = %directory.display(), "Serving the web UI at {UI_PATH}/"); + Some(directory) + } else { + tracing::warn!( + directory = %directory.display(), + "The web UI is enabled but {}/index.html does not exist: not serving it. \ + Build it (`npm run build` in frontend/), set SENSAPP_UI_DIR, or set SENSAPP_UI_ENABLED=false", + directory.display() + ); + None + } +} + +/// Serve the UI from `directory` under `/ui/`, and redirect `/` to it. Paths that are no file go to +/// `index.html`: the UI routes in the browser. +pub fn add_ui(router: Router, directory: &Path) -> Router +where + S: Clone + Send + Sync + 'static, +{ + let files = ServeDir::new(directory).fallback(ServeFile::new(directory.join("index.html"))); + let files = tower::ServiceBuilder::new() + // Revalidate always: a new version must not be served with the assets of the old one + .layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CONTENT_SECURITY_POLICY), + )) + .layer(SetResponseHeaderLayer::overriding( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .service(files); + router + .route("/", get(|| async { Redirect::temporary("/ui/") })) + .nest_service(UI_PATH, files) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + /// A directory that looks like a built frontend, removed on drop. + struct Dist(PathBuf); + + impl Dist { + fn new() -> Self { + let directory = + std::env::temp_dir().join(format!("sensapp-ui-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(directory.join("assets")).unwrap(); + std::fs::write(directory.join("index.html"), "SensApp UI").unwrap(); + std::fs::write(directory.join("assets/app.js"), "console.log('ui')").unwrap(); + Self(directory) + } + } + + impl Drop for Dist { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + async fn get_path(router: &Router, path: &str) -> (StatusCode, axum::http::HeaderMap, String) { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let (parts, body) = response.into_parts(); + let body = to_bytes(body, 1024 * 1024).await.unwrap(); + ( + parts.status, + parts.headers, + String::from_utf8_lossy(&body).into(), + ) + } + + #[tokio::test] + async fn serves_the_files_the_index_and_the_routes_of_the_ui() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + + for path in [ + "/ui", + "/ui/", + "/ui/index.html", + "/ui/some/route", + "/ui/series/abc", + ] { + let (status, _, body) = get_path(&router, path).await; + assert_eq!(status, StatusCode::OK, "{path}"); + assert_eq!(body, "SensApp UI", "{path}"); + } + + let (status, headers, body) = get_path(&router, "/ui/assets/app.js").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "console.log('ui')"); + assert!( + headers["content-type"] + .to_str() + .unwrap() + .contains("javascript") + ); + } + + #[tokio::test] + async fn redirects_the_root_to_the_ui() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + let (status, headers, _) = get_path(&router, "/").await; + assert_eq!(status, StatusCode::TEMPORARY_REDIRECT); + assert_eq!(headers["location"], "/ui/"); + } + + #[tokio::test] + async fn protects_the_browser_that_holds_a_token() { + let dist = Dist::new(); + let router = add_ui(Router::new(), &dist.0); + for path in ["/ui/", "/ui/assets/app.js"] { + let (_, headers, _) = get_path(&router, path).await; + assert!( + headers["content-security-policy"] + .to_str() + .unwrap() + .contains("default-src 'self'") + ); + assert_eq!(headers["x-content-type-options"], "nosniff"); + assert_eq!(headers["cache-control"], "no-cache"); + } + } + + #[tokio::test] + async fn leaves_the_other_routes_alone() { + let dist = Dist::new(); + let router = add_ui( + Router::new().route("/metrics", get(|| async { "api" })), + &dist.0, + ); + let (_, headers, body) = get_path(&router, "/metrics").await; + assert_eq!(body, "api"); + assert!(!headers.contains_key("content-security-policy")); + let (status, _, _) = get_path(&router, "/other").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[test] + fn serves_only_a_built_and_enabled_ui() { + let dist = Dist::new(); + let config = |enabled: &str, directory: &str| { + temp_env::with_vars( + [ + ("SENSAPP_UI_ENABLED", Some(enabled)), + ("SENSAPP_UI_DIR", Some(directory)), + ], + || SensAppConfig::load().unwrap(), + ) + }; + let built = dist.0.to_str().unwrap(); + assert_eq!(ui_directory(&config("true", built)), Some(dist.0.clone())); + assert_eq!(ui_directory(&config("false", built)), None); + assert_eq!(ui_directory(&config("true", "/no/such/directory")), None); + } +} diff --git a/src/main.rs b/src/main.rs index 8f6535a9..d1abc1ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ #![forbid(unsafe_code)] use anyhow::{Context, Result}; use sensapp::config::{self, load_configuration}; -use sensapp::http::auth::AuthConfig; +use sensapp::http::auth::{ + AuthConfig, AuthMode, TokenRequest, TokenRules, generate_secret, resolve_auth_mode, +}; use sensapp::http::metrics::HttpMetrics; use sensapp::http::server::run_http_server; use sensapp::http::state::HttpServerState; @@ -17,6 +19,10 @@ fn main() -> Result<()> { if args.len() > 1 && args[1] == "generate-token" { return generate_token_command(&args[2..]); } + if args.len() > 1 && args[1] == "generate-secret" { + println!("{}", generate_secret()?); + return Ok(()); + } rustls::crypto::aws_lc_rs::default_provider() .install_default() @@ -43,6 +49,15 @@ async fn async_main() -> Result<()> { load_configuration().context("Failed to load configuration")?; let config = config::get().context("Failed to get configuration")?; + // Decide how to authenticate before anything else: SensApp refuses to start, before it + // connects to the storage, when it would be open by mistake + let auth_mode = resolve_auth_mode( + config.jwt_secret.as_deref(), + config.auth_disabled, + config.endpoint, + ) + .context("Authentication is not configured")?; + // Initialize Sentry if DSN is provided let _sentry = config.sentry_dsn.as_ref().map(|dsn| { sentry::init(( @@ -87,19 +102,7 @@ async fn async_main() -> Result<()> { let address = SocketAddr::from((endpoint, port)); println!("📡 Starting HTTP server on http://{}...", address); - // Build optional JWT authentication config - let auth = match &config.jwt_secret { - Some(secret) => { - let auth_config = AuthConfig::from_secret(secret) - .context("Failed to configure JWT authentication")?; - println!("🔐 JWT authentication enabled"); - Some(auth_config) - } - None => { - println!("🔓 No JWT secret configured — all endpoints are open"); - None - } - }; + let auth = build_auth(&auth_mode, &config, address)?; match run_http_server( HttpServerState { @@ -125,12 +128,79 @@ async fn async_main() -> Result<()> { } } +/// How long the token printed at the start of a run with a made secret is valid. The secret is +/// lost with the process, so the CLI cannot mint another one: it has to last a working day. +const EPHEMERAL_TOKEN_SECONDS: u64 = 24 * 3600; + +/// Build the authentication of the server from the decided mode, and tell the operator about it. +fn build_auth( + mode: &AuthMode, + config: &config::SensAppConfig, + address: SocketAddr, +) -> Result> { + let previous_secrets = config.previous_jwt_secrets(); + match mode { + AuthMode::Secret(secret) => { + if config.auth_disabled { + println!("⚠️ SENSAPP_AUTH_DISABLED is ignored: SENSAPP_JWT_SECRET is set"); + } + let auth_config = AuthConfig::from_secret(secret) + .context("Failed to configure JWT authentication")? + .with_previous_secrets(&previous_secrets)? + .with_max_token_duration(config.token_max_duration_seconds); + println!("🔐 JWT authentication enabled"); + if !previous_secrets.is_empty() { + println!( + "🔑 {} previous secret(s) still verify tokens", + previous_secrets.len() + ); + } + Ok(Some(auth_config)) + } + AuthMode::Ephemeral(secret) => { + let auth_config = AuthConfig::from_secret(secret) + .context("Failed to configure JWT authentication")?; + if !previous_secrets.is_empty() { + println!( + "⚠️ SENSAPP_JWT_PREVIOUS_SECRETS is ignored: there is no SENSAPP_JWT_SECRET" + ); + } + // Every scope, as this token is the only way in: the secret is not known to the CLI + let token = auth_config + .issue_token( + "local-dev", + "read write delete admin", + EPHEMERAL_TOKEN_SECONDS, + None, + ) + .context("Failed to create the local token")? + .token; + println!("🔐 JWT authentication enabled, with a secret made for this run"); + println!(" It is lost when SensApp stops, and tokens made before are refused."); + println!(" Set SENSAPP_JWT_SECRET to keep it (`sensapp generate-secret` makes one)."); + println!(" Token for this run, valid for 24 hours:"); + println!(" {token}"); + if config.ui_enabled { + println!(" Open the UI signed in: http://{address}/ui/#token={token}"); + } + Ok(Some(auth_config)) + } + AuthMode::Disabled => { + println!( + "🔓 SENSAPP_AUTH_DISABLED is set: every endpoint is open, to anyone who can reach this server" + ); + Ok(None) + } + } +} + /// CLI subcommand: generate a signed JWT token. /// /// Usage: sensapp generate-token [OPTIONS] -/// --scope (default: "read write") +/// --scope (default: "read write") /// --duration (default: 3600) -/// --sensors (optional sensor allow list) +/// --sensors (optional sensor allow list, comma separated) +/// --sensor (one sensor name as it is, commas included; repeat it) fn generate_token_command(args: &[String]) -> Result<()> { load_configuration().context("Failed to load configuration")?; let config = config::get().context("Failed to get configuration")?; @@ -148,10 +218,15 @@ fn generate_token_command(args: &[String]) -> Result<()> { eprintln!(); eprintln!("Options:"); eprintln!( - " --scope Comma-separated read, write, delete, or readwrite (default: \"read write\")" + " --scope Comma-separated read, write, delete, admin, or readwrite (default: \"read write\")" ); eprintln!(" --duration Token validity duration (default: 3600)"); - eprintln!(" --sensors Restrict to specific sensors"); + eprintln!( + " --sensors Restrict to specific sensors, comma separated" + ); + eprintln!( + " --sensor Restrict to one sensor, named as it is (a comma is part of the name). Repeat it" + ); eprintln!(); eprintln!("Environment:"); eprintln!(" SENSAPP_JWT_SECRET Required. The shared secret for signing."); @@ -168,8 +243,7 @@ fn generate_token_command(args: &[String]) -> Result<()> { match args[i].as_str() { "--scope" => { i += 1; - let raw = args.get(i).context("--scope requires a value")?; - scope = parse_scope_argument(raw)?; + scope = args.get(i).context("--scope requires a value")?.clone(); } "--duration" => { i += 1; @@ -181,7 +255,17 @@ fn generate_token_command(args: &[String]) -> Result<()> { "--sensors" => { i += 1; let raw = args.get(i).context("--sensors requires a value")?; - sensors = Some(raw.split(',').map(|s| s.trim().to_string()).collect()); + sensors.get_or_insert_with(Vec::new).extend( + raw.split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + ); + } + "--sensor" => { + i += 1; + let name = args.get(i).context("--sensor requires a name")?; + sensors.get_or_insert_with(Vec::new).push(name.clone()); } other => { anyhow::bail!("Unknown option: {other}"); @@ -190,60 +274,13 @@ fn generate_token_command(args: &[String]) -> Result<()> { i += 1; } - let token = auth_config - .create_token(subject, &scope, duration, sensors) - .context("Failed to create token")?; - - println!("{token}"); + // The secret is what gives the right to make any token, so the command line has no cap but a + // sane one, and is the only way to make an admin token + let rules = TokenRules { + max_duration_seconds: 10 * 365 * 24 * 3600, + allow_admin: true, + }; + let request = TokenRequest::new(subject, &scope, sensors, duration, &rules)?; + println!("{}", request.issue(&auth_config)?.token); Ok(()) } - -/// Parse the `--scope` value: comma or space separated `read`, `write`, `delete`, -/// with `readwrite` as a shorthand for `read write`. -fn parse_scope_argument(raw: &str) -> Result { - let mut scopes: Vec<&str> = Vec::new(); - for item in raw.split([',', ' ']).filter(|item| !item.is_empty()) { - let expanded: &[&str] = match item { - "read" => &["read"], - "write" => &["write"], - "delete" => &["delete"], - "readwrite" => &["read", "write"], - other => anyhow::bail!("Unknown scope: {other}. Use read, write, delete, or readwrite"), - }; - for scope in expanded { - if !scopes.contains(scope) { - scopes.push(scope); - } - } - } - if scopes.is_empty() { - anyhow::bail!("--scope requires at least one scope"); - } - Ok(scopes.join(" ")) -} - -#[cfg(test)] -mod scope_argument_tests { - use super::parse_scope_argument; - - #[test] - fn parses_scope_combinations() { - assert_eq!(parse_scope_argument("read").unwrap(), "read"); - assert_eq!(parse_scope_argument("readwrite").unwrap(), "read write"); - assert_eq!(parse_scope_argument("read write").unwrap(), "read write"); - assert_eq!(parse_scope_argument("read,write").unwrap(), "read write"); - assert_eq!(parse_scope_argument("delete").unwrap(), "delete"); - assert_eq!( - parse_scope_argument("readwrite,delete").unwrap(), - "read write delete" - ); - assert_eq!(parse_scope_argument("read,read").unwrap(), "read"); - } - - #[test] - fn rejects_unknown_or_empty_scopes() { - assert!(parse_scope_argument("admin").is_err()); - assert!(parse_scope_argument("read,admin").is_err()); - assert!(parse_scope_argument("").is_err()); - } -} diff --git a/tests/clickhouse_container_smoke.py b/tests/clickhouse_container_smoke.py index cb705fe2..2cc4eaa6 100644 --- a/tests/clickhouse_container_smoke.py +++ b/tests/clickhouse_container_smoke.py @@ -3,6 +3,7 @@ import argparse import json +import re import sys import time import urllib.error @@ -40,6 +41,20 @@ def wait_for_readiness(base_url: str, expected_status: int, timeout: int) -> Non raise AssertionError(f"Readiness did not reach HTTP {expected_status}: {last_result}") +def check_ui(base_url: str) -> None: + """The image ships the web UI: `/` leads to it, and the script it loads is there.""" + status, body = request(base_url, "/") # urllib follows the redirect to /ui/ + if status != 200 or '
' not in body: + raise AssertionError(f"The web UI is not served at /: HTTP {status}: {body[:200]}") + script = re.search(r'src="(/ui/assets/[^"]+\.js)"', body) + if not script: + raise AssertionError(f"The web UI page loads no script: {body[:200]}") + status, _ = request(base_url, script.group(1)) + if status != 200: + raise AssertionError(f"The web UI script {script.group(1)} answers HTTP {status}") + print(f"Web UI served, with {script.group(1)}") + + def lifecycle(base_url: str) -> None: wait_for_readiness(base_url, 200, 120) metric = f"sensapp_ci_{uuid.uuid4().hex}" @@ -65,6 +80,7 @@ def lifecycle(base_url: str) -> None: if status != 200 or not body.strip(): raise AssertionError(f"Service metrics unavailable: HTTP {status}: {body}") print(f"Published and queried {metric}; service metrics available") + check_ui(base_url) def main() -> int: diff --git a/tests/integration/jwt_auth.rs b/tests/integration/jwt_auth.rs index fa38e8f0..dbff0187 100644 --- a/tests/integration/jwt_auth.rs +++ b/tests/integration/jwt_auth.rs @@ -51,6 +51,9 @@ struct TestClaims { scope: Option, #[serde(skip_serializing_if = "Option::is_none")] sensors: Option>, + // The issuer and the audience SensApp requires, set by `TestClaims::new` + iss: &'static str, + aud: &'static str, } fn make_token(claims: &TestClaims) -> String { @@ -70,6 +73,8 @@ fn read_write_token() -> String { iat: Some(0), scope: Some("read write".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } @@ -81,6 +86,8 @@ fn sensor_token(scope: &str, sensors: &[&str]) -> String { iat: None, scope: Some(scope.into()), sensors: Some(sensors.iter().map(|sensor| (*sensor).into()).collect()), + iss: "sensapp", + aud: "sensapp", }) } @@ -92,6 +99,8 @@ fn read_only_token() -> String { iat: Some(0), scope: Some("read".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } @@ -103,6 +112,8 @@ fn write_only_token() -> String { iat: Some(0), scope: Some("write".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } @@ -114,6 +125,8 @@ fn expired_token() -> String { iat: None, scope: Some("read write".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } @@ -125,6 +138,8 @@ fn not_yet_valid_token() -> String { iat: None, scope: Some("read write".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } @@ -400,6 +415,8 @@ async fn auth_rejects_wrong_secret() { iat: None, scope: Some("read write".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }, &EncodingKey::from_secret(b"wrong-secret-that-is-long-enough!!"), ) @@ -564,6 +581,116 @@ async fn prometheus_metrics_is_always_public() { assert_ne!(resp.status(), StatusCode::FORBIDDEN); } +// --------------------------------------------------------------------------- +// Tests: token scheme, issuer and rotated secrets +// --------------------------------------------------------------------------- + +async fn status_of(app: Router, request: Request) -> StatusCode { + app.oneshot(request).await.unwrap().status() +} + +#[tokio::test] +#[serial] +async fn auth_accepts_the_token_scheme_of_influxdb_clients() { + let db = TestDb::new().await.expect("test db"); + let auth = AuthConfig::from_secret(TEST_SECRET).unwrap(); + let issued = auth + .issue_token("telegraf", "read write", 3600, None) + .unwrap(); + let app = build_test_router(db.storage(), Some(auth)); + + let write = Request::post("/publish") + .header("authorization", format!("Token {}", issued.token)) + .header("content-type", "application/json") + .body(Body::from( + r#"[{"n":"temperature","u":"Cel","v":22.5,"t":1700000000}]"#, + )) + .unwrap(); + let status = status_of(app.clone(), write).await; + assert_ne!(status, StatusCode::UNAUTHORIZED); + assert_ne!(status, StatusCode::FORBIDDEN); + + let read = Request::get("/metrics") + .header("authorization", format!("Token {}", issued.token)) + .body(Body::empty()) + .unwrap(); + assert_eq!(status_of(app.clone(), read).await, StatusCode::OK); + + // Another scheme is still not a token + let basic = Request::get("/metrics") + .header("authorization", format!("Basic {}", issued.token)) + .body(Body::empty()) + .unwrap(); + assert_eq!(status_of(app, basic).await, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn auth_rejects_a_token_of_another_issuer() { + let db = TestDb::new().await.expect("test db"); + let auth = AuthConfig::from_secret(TEST_SECRET).unwrap(); + let app = build_test_router(db.storage(), Some(auth)); + + // Signed with the right secret, but by another system + let token = encode( + &Header::default(), + &TestClaims { + sub: "other-system".into(), + exp: 4_102_444_800, + nbf: None, + iat: None, + scope: Some("read write".into()), + sensors: None, + iss: "another-system", + aud: "sensapp", + }, + &EncodingKey::from_secret(TEST_SECRET.as_bytes()), + ) + .unwrap(); + let request = Request::get("/metrics") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + assert_eq!(status_of(app, request).await, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn auth_secret_rotation_keeps_then_drops_the_old_tokens() { + const OLD_SECRET: &str = "old-secret-0123456789abcdef0123456789"; + let db = TestDb::new().await.expect("test db"); + let old_token = AuthConfig::from_secret(OLD_SECRET) + .unwrap() + .issue_token("client", "read", 3600, None) + .unwrap() + .token; + let get_metrics = |token: &str| { + Request::get("/metrics") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + + // Rotation under way: the new secret signs, the old one still verifies + let rotating = AuthConfig::from_secret(TEST_SECRET) + .unwrap() + .with_previous_secrets(&[OLD_SECRET.to_string()]) + .unwrap(); + let app = build_test_router(db.storage(), Some(rotating)); + assert_eq!( + status_of(app, get_metrics(&old_token)).await, + StatusCode::OK + ); + + // The old secret is dropped: its tokens are revoked + let rotated = AuthConfig::from_secret(TEST_SECRET).unwrap(); + let app = build_test_router(db.storage(), Some(rotated)); + assert_eq!( + status_of(app, get_metrics(&old_token)).await, + StatusCode::UNAUTHORIZED + ); +} + // --------------------------------------------------------------------------- // Tests: token creation via AuthConfig // --------------------------------------------------------------------------- @@ -572,8 +699,9 @@ async fn prometheus_metrics_is_always_public() { fn auth_config_create_token_roundtrip() { let config = AuthConfig::from_secret(TEST_SECRET).unwrap(); let token = config - .create_token("my-service", "read write", 3600, None) - .expect("token creation"); + .issue_token("my-service", "read write", 3600, None) + .expect("token creation") + .token; // Validate it by decoding let mut headers = axum::http::HeaderMap::new(); @@ -592,13 +720,14 @@ fn auth_config_create_token_roundtrip() { fn auth_config_create_token_with_sensor_filter() { let config = AuthConfig::from_secret(TEST_SECRET).unwrap(); let token = config - .create_token( + .issue_token( "sensor-reader", "read", 3600, Some(vec!["cpu_temp".to_string(), "fan_speed".to_string()]), ) - .expect("token creation"); + .expect("token creation") + .token; let mut headers = axum::http::HeaderMap::new(); headers.insert( @@ -836,6 +965,8 @@ fn delete_token() -> String { iat: None, scope: Some("delete".into()), sensors: None, + iss: "sensapp", + aud: "sensapp", }) } diff --git a/tests/integration/real_router.rs b/tests/integration/real_router.rs index df2fd436..b1bfa4dd 100644 --- a/tests/integration/real_router.rs +++ b/tests/integration/real_router.rs @@ -8,7 +8,6 @@ use async_trait::async_trait; use axum::Router; use axum::body::Body; use axum::http::{HeaderMap, Request, StatusCode}; -use jsonwebtoken::{EncodingKey, Header, encode}; use sensapp::config::load_configuration_for_tests; use sensapp::datamodel::batch::Batch; use sensapp::datamodel::{Metric, SensAppDateTime, SensorData}; @@ -17,7 +16,6 @@ use sensapp::http::metrics::HttpMetrics; use sensapp::http::server::{RouterSettings, build_router}; use sensapp::http::state::HttpServerState; use sensapp::storage::{LabelMatcher, ListSeriesResult, StorageInstance}; -use serde::Serialize; use serial_test::serial; use std::sync::Arc; use std::time::Duration; @@ -124,6 +122,7 @@ fn settings() -> RouterSettings { write_timeout: Duration::from_secs(300), maintenance_timeout: Duration::from_secs(3600), max_concurrent_writes: 16, + ui_dir: None, } } @@ -158,31 +157,17 @@ async fn router_with( Ok((test_db, build_router(state, &settings))) } -#[derive(Serialize)] -struct Claims { - sub: String, - exp: u64, - scope: String, - #[serde(skip_serializing_if = "Option::is_none")] - sensors: Option>, -} - fn token(scope: &str) -> String { token_for_sensors(scope, None) } +/// A token made the way `sensapp generate-token` makes it. fn token_for_sensors(scope: &str, sensors: Option>) -> String { - encode( - &Header::default(), - &Claims { - sub: "test".into(), - exp: 4_102_444_800, - scope: scope.into(), - sensors, - }, - &EncodingKey::from_secret(SECRET.as_bytes()), - ) - .expect("token") + AuthConfig::from_secret(SECRET) + .expect("secret") + .issue_token("test", scope, 3600, sensors) + .expect("token") + .token } fn write_request(token: Option<&str>) -> Request { @@ -645,3 +630,286 @@ async fn the_vacuum_has_a_timeout_of_its_own() -> Result<()> { assert_eq!(status, StatusCode::GATEWAY_TIMEOUT); Ok(()) } + +/// The UI files are public, they hold no data. Its API calls need the token as any other client. +#[tokio::test] +#[serial] +async fn the_ui_is_public_and_its_data_is_not() -> Result<()> { + let dist = std::env::temp_dir().join(format!("sensapp-ui-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dist)?; + std::fs::write(dist.join("index.html"), "SensApp UI")?; + let settings = RouterSettings { + ui_dir: Some(dist.clone()), + ..settings() + }; + let auth = AuthConfig::from_secret(SECRET)?; + let (_db, router) = router(Duration::ZERO, Some(auth), settings).await?; + + let (status, headers, _) = send(&router, get_request("/")).await; + assert_eq!(status, StatusCode::TEMPORARY_REDIRECT); + assert_eq!(headers["location"], "/ui/"); + + let (status, _, body) = send(&router, get_request("/ui/")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "SensApp UI"); + + // The catalog behind it answers 401 without a token, which is what makes the UI ask for one + let (status, _, body) = send(&router, get_request("/metrics")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(body.contains("Authorization"), "{body}"); + let request = Request::builder() + .uri("/metrics") + .header("authorization", format!("Bearer {}", token("read"))) + .body(Body::empty())?; + let (status, _, _) = send(&router, request).await; + assert_eq!(status, StatusCode::OK); + + std::fs::remove_dir_all(dist)?; + Ok(()) +} + +/// Without the UI, `/` is the name of the instance as before. +#[tokio::test] +#[serial] +async fn the_root_is_the_name_of_the_instance_without_the_ui() -> Result<()> { + let (_db, router) = router(Duration::ZERO, None, settings()).await?; + let (status, _, body) = send(&router, get_request("/")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "\"SensApp Test\""); + let (status, _, _) = send(&router, get_request("/ui/")).await; + assert_eq!(status, StatusCode::NOT_FOUND); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Making tokens: POST /api/v1/admin/tokens +// --------------------------------------------------------------------------- + +fn create_token_request(token: Option<&str>, body: serde_json::Value) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri("/api/v1/admin/tokens") + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + builder.body(Body::from(body.to_string())).unwrap() +} + +fn bearer_get(path: &str, token: &str) -> Request { + Request::builder() + .method("GET") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() +} + +fn token_wish(subject: &str, scope: &[&str], duration_seconds: u64) -> serde_json::Value { + serde_json::json!({ + "subject": subject, + "scope": scope, + "duration_seconds": duration_seconds, + }) +} + +#[tokio::test] +#[serial] +async fn an_admin_makes_tokens_that_do_what_they_were_made_for() -> Result<()> { + let auth = Some(AuthConfig::from_secret(SECRET)?); + let (_db, router) = router(Duration::ZERO, auth, settings()).await?; + let admin = token("admin"); + + let (status, headers, body) = send( + &router, + create_token_request(Some(&admin), token_wish("dashboard", &["read"], 3600)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + // A token does not stay in a cache + assert_eq!(headers.get("cache-control").unwrap(), "no-store"); + + let created: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!(created["subject"], "dashboard"); + assert_eq!(created["scope"], serde_json::json!(["read"])); + assert!(created["sensors"].is_null()); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let expires_at = created["expires_at"].as_u64().unwrap(); + assert!( + expires_at.abs_diff(now + 3600) < 10, + "{expires_at} vs {now}" + ); + let made = created["token"].as_str().unwrap(); + + // The token reads, and does not write or make tokens + let (status, _, _) = send(&router, bearer_get("/metrics", made)).await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = send(&router, write_request(Some(made))).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = send( + &router, + create_token_request(Some(made), token_wish("more", &["read"], 60)), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // The id of the answer is the one in the token, which the logs of its requests carry + let validated = sensapp::http::auth::validate_token( + &{ + let mut headers = HeaderMap::new(); + headers.insert("authorization", format!("Bearer {made}").parse()?); + headers + }, + &AuthConfig::from_secret(SECRET)?, + ) + .expect("the made token validates"); + assert_eq!(validated.token_id.as_deref(), created["jti"].as_str()); + + // The sensors of the request limit the token + let mut wish = token_wish("one-sensor", &["read", "write"], 60); + wish["sensors"] = serde_json::json!(["only,this", "only,this"]); + let (status, _, body) = send(&router, create_token_request(Some(&admin), wish)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let created: serde_json::Value = serde_json::from_str(&body)?; + // A comma is part of a name, and a repeated name is kept once + assert_eq!(created["sensors"], serde_json::json!(["only,this"])); + assert_eq!(created["scope"], serde_json::json!(["read", "write"])); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn only_admin_tokens_make_tokens_and_admin_tokens_do_nothing_else() -> Result<()> { + let auth = Some(AuthConfig::from_secret(SECRET)?); + let (_db, router) = router(Duration::ZERO, auth, settings()).await?; + let wish = || token_wish("someone", &["read"], 60); + + let (status, _, _) = send(&router, create_token_request(None, wish())).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + for scope in ["read", "write", "delete", "read write delete"] { + let (status, _, _) = send(&router, create_token_request(Some(&token(scope)), wish())).await; + assert_eq!(status, StatusCode::FORBIDDEN, "scope {scope}"); + } + + // Admin is not read, write or delete either + let admin = token("admin"); + let (status, _, _) = send(&router, bearer_get("/metrics", &admin)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = send(&router, write_request(Some(&admin))).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = send(&router, vacuum_request(Some(&admin))).await; + assert_eq!(status, StatusCode::FORBIDDEN); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn the_endpoint_checks_the_request() -> Result<()> { + let auth = Some(AuthConfig::from_secret(SECRET)?.with_max_token_duration(3600)); + let (_db, router) = router(Duration::ZERO, auth, settings()).await?; + let admin = token("admin"); + let ask = |wish: serde_json::Value| { + let router = router.clone(); + let admin = admin.clone(); + async move { + let (status, _, body) = send(&router, create_token_request(Some(&admin), wish)).await; + (status, body) + } + }; + + // An admin token is never made here, even among others: it takes the secret + for scope in [&["admin"][..], &["read", "admin"][..]] { + let (status, body) = ask(token_wish("x", scope, 60)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + let invalid = [ + token_wish("", &["read"], 60), + token_wish(" ", &["read"], 60), + token_wish("x", &[], 60), + token_wish("x", &["root"], 60), + token_wish("x", &["read"], 0), + // The configured maximum + token_wish("x", &["read"], 3601), + token_wish("x", &["read"], u64::MAX), + { + let mut wish = token_wish("x", &["read"], 60); + wish["sensors"] = serde_json::json!([]); + wish + }, + { + let mut wish = token_wish("x", &["read"], 60); + wish["sensors"] = serde_json::json!([""]); + wish + }, + ]; + for wish in invalid { + let (status, body) = ask(wish.clone()).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{wish} gave {body}"); + } + let (status, body) = ask(token_wish("x", &["read"], 3600)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + + // Not JSON of the right shape + let (status, _, _) = send( + &router, + create_token_request(Some(&admin), serde_json::json!({"subject": "x"})), + ) + .await; + assert!(status.is_client_error()); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn without_authentication_there_is_nothing_to_sign_with() -> Result<()> { + let (_db, router) = router(Duration::ZERO, None, settings()).await?; + let (status, _, body) = send( + &router, + create_token_request(None, token_wish("x", &["read"], 60)), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert!(body.contains("disabled"), "{body}"); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn creating_a_token_is_logged_without_the_token() -> Result<()> { + let auth = Some(AuthConfig::from_secret(SECRET)?); + let (_db, router) = router(Duration::ZERO, auth, settings()).await?; + let (logs, _guard) = capture_logs(); + + let (status, _, body) = send( + &router, + create_token_request(Some(&token("admin")), token_wish("audited", &["write"], 60)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let created: serde_json::Value = serde_json::from_str(&body)?; + let made = created["token"].as_str().unwrap(); + let jti = created["jti"].as_str().unwrap(); + + let lines = logs.lines(); + let line = lines + .iter() + .find(|line| line.contains("token created")) + .unwrap_or_else(|| panic!("no log of the creation in {lines:#?}")); + // Who asked, for whom, with what, and which token + assert!( + line.contains("creator=\"test\"") || line.contains("creator=Some(\"test\")"), + "{line}" + ); + assert!(line.contains("audited"), "{line}"); + assert!(line.contains(jti), "{line}"); + // The request span carries who asked + assert!(line.contains("subject=\"test\""), "{line}"); + // No line shows the token, nor the header that carried the admin one + for line in &lines { + assert!(!line.contains(made), "{line}"); + } + Ok(()) +} diff --git a/tests/integration/rrdcached_integration.rs b/tests/integration/rrdcached_integration.rs index 0bccf420..43ba738b 100644 --- a/tests/integration/rrdcached_integration.rs +++ b/tests/integration/rrdcached_integration.rs @@ -668,8 +668,10 @@ mod rrdcached_tests { // archive of ten seconds let db = connect().await?; let storage = db.storage(); + // The end is in the middle of a ten minutes interval, wherever the clock is: the + // outcome depends on it (see the next test). The clock does not matter to the daemon. + let end = now() - now() % 600 - 300; let sensor = new_sensor(); - let end = now(); // Twenty-five minutes of data: the rows of ten minutes before the last one are complete let points = ramp(end - 1500, 10, 151, 1.0); publish(&storage, &sensor, &points).await?; @@ -682,6 +684,27 @@ mod rrdcached_tests { Ok(()) } + #[tokio::test] + #[serial] + async fn a_last_update_at_the_end_of_a_ten_minutes_interval_is_in_the_coarse_row() -> Result<()> + { + // The row of ten minutes that ends with the last update is complete: there is nothing + // fresher to read, and its value is the average of the ten minutes, not the last sample + let db = connect().await?; + let storage = db.storage(); + let end = now() - now() % 600 - 600; + let sensor = new_sensor(); + let points = ramp(end - 1500, 10, 151, 1.0); + publish(&storage, &sensor, &points).await?; + + let rows = read(&storage, &sensor, end - 3 * 86400, end).await?; + let last = rows.last().unwrap(); + assert_eq!(last.0, end, "{rows:?}"); + // 91 to 150: the samples of the interval (end - 600, end] + assert_eq!(last.1, (91..=150).sum::() as f64 / 60.0, "{rows:?}"); + Ok(()) + } + #[tokio::test] #[serial] async fn the_munin_preset_keeps_five_minutes_rows() -> Result<()> { @@ -908,6 +931,7 @@ mod rrdcached_tests { write_timeout: Duration::from_secs(300), maintenance_timeout: Duration::from_secs(3600), max_concurrent_writes: 16, + ui_dir: None, }, ) } diff --git a/tests/perf/dedup.sh b/tests/perf/dedup.sh index 557c622d..bc91c631 100755 --- a/tests/perf/dedup.sh +++ b/tests/perf/dedup.sh @@ -18,7 +18,7 @@ WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT # shellcheck disable=SC2086 -env SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +env SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=600 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 ${EXTRA_ENV:-} \ "$BIN" > "$WORK/server.log" 2>&1 & PID=$! diff --git a/tests/perf/scale.sh b/tests/perf/scale.sh index 117fa6c6..f05de10f 100755 --- a/tests/perf/scale.sh +++ b/tests/perf/scale.sh @@ -15,7 +15,7 @@ PORT=${PORT:-3980} WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT -SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=300 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 "$BIN" > "$WORK/server.log" 2>&1 & PID=$! for _ in $(seq 1 60); do curl -sf "localhost:$PORT/health/ready" >/dev/null && break; sleep 0.5; done diff --git a/tests/perf/strings.sh b/tests/perf/strings.sh index 13b78eed..4faec258 100755 --- a/tests/perf/strings.sh +++ b/tests/perf/strings.sh @@ -16,7 +16,7 @@ PORT=${PORT:-3981} WORK=$(mktemp -d) trap 'kill "$PID" 2>/dev/null; wait "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT -SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ +SENSAPP_AUTH_DISABLED=true SENSAPP_PORT=$PORT SENSAPP_STORAGE_CONNECTION_STRING="$CONN" SENSAPP_SETTINGS_FILE=/nonexistent.toml \ SENSAPP_HTTP_SERVER_TIMEOUT_SECONDS=300 SENSAPP_HTTP_MAX_CONCURRENT_WRITES=0 "$BIN" > "$WORK/server.log" 2>&1 & PID=$! for _ in $(seq 1 60); do curl -sf "localhost:$PORT/health/ready" >/dev/null && break; sleep 0.5; done diff --git a/tests/prometheus_live/README.md b/tests/prometheus_live/README.md index 4fd1405e..55d93047 100644 --- a/tests/prometheus_live/README.md +++ b/tests/prometheus_live/README.md @@ -1,6 +1,6 @@ # Live Prometheus compatibility test -This test runs a real Prometheus v2.55.1 against SensApp backed by PostgreSQL. It checks remote write and remote read independently. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. +This test runs a real Prometheus (v3.13.4, the LTS line, or the image of `PROMETHEUS_IMAGE`) against SensApp backed by PostgreSQL. It checks remote write and remote read independently, and that what Prometheus computes from the samples of a remote read, with the hints it sends (`avg_over_time(x[1h])` with a step...), is what the raw samples give. The test uses ports 3017 (SensApp) and 9099 (Prometheus), and removes the Prometheus container when done. Start a local PostgreSQL server with an empty database, then run: diff --git a/tests/prometheus_live/run.sh b/tests/prometheus_live/run.sh index 1da4c3f6..ea71f9f6 100644 --- a/tests/prometheus_live/run.sh +++ b/tests/prometheus_live/run.sh @@ -30,7 +30,8 @@ trap cleanup EXIT cd "$repo_root" cargo build --locked --no-default-features --features postgres -SENSAPP_ENDPOINT=0.0.0.0 SENSAPP_PORT=3017 \ +# The test clients send no token: run open, explicitly +SENSAPP_AUTH_DISABLED=true SENSAPP_ENDPOINT=0.0.0.0 SENSAPP_PORT=3017 \ "$repo_root/target/debug/sensapp" >"$work_dir/sensapp.log" 2>&1 & sensapp_pid=$! @@ -39,7 +40,7 @@ docker run --detach \ --add-host host.docker.internal:host-gateway \ --publish 127.0.0.1:9099:9090 \ --volume "$repo_root/tests/prometheus_live/prometheus.yml:/etc/prometheus/prometheus.yml:ro" \ - prom/prometheus:v2.55.1 \ + "${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.4}" \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus diff --git a/tests/prometheus_live/test_live.py b/tests/prometheus_live/test_live.py index b9c8eb33..f5624fef 100644 --- a/tests/prometheus_live/test_live.py +++ b/tests/prometheus_live/test_live.py @@ -102,11 +102,121 @@ def queried_from_prometheus() -> tuple[bool, str]: wait_for("Prometheus remote read returned the SensApp-only probe", queried_from_prometheus) +HOUR = 3600 +MINUTES = 6 * 60 + + +def hint_value(series: str, minute: int) -> float: + """Values that move all the time, so a wrong window or bucket changes the answer.""" + if series == "a": + return ((minute * 7919) % 1000) / 10.0 + return 500.0 + ((minute * 104729) % 1000) / 10.0 + + +def query_range(expression: str, start: int, end: int, step: int) -> dict[int, float]: + params = urllib.parse.urlencode( + {"query": expression, "start": start, "end": end, "step": step} + ) + status, body = request(PROMETHEUS, "/api/v1/query_range?" + params) + if status != 200: + raise AssertionError(f"{expression}: Prometheus HTTP {status}: {body[:300]}") + result = json.loads(body)["data"]["result"] + if len(result) != 1: + raise AssertionError(f"{expression}: expected one series, got {result}") + return {int(float(t)): float(v) for t, v in result[0]["values"]} + + +def test_remote_read_hints() -> None: + """Prometheus evaluates the query itself on what SensApp returns, so what SensApp returns + for a hint (`func`, `step`, `range`) must give the same answer as the raw samples. + + Prometheus asks for the window (t - range, t] of every evaluation time t, and SensApp + answers with one bucket per `step`: this is exact when the range is a multiple of the step + for the functions that can be merged (min, max, sum, last), and the average is then an + average of equal-sized buckets. Everything else must be answered with the raw samples. + """ + tag = uuid.uuid4().hex[:8] + t0 = int(time.time()) // HOUR * HOUR - 7 * HOUR + names = {s: f"hints_{tag}_{s}" for s in "ab"} + payload = [ + {"n": names[s], "v": hint_value(s, m), "t": t0 + 60 * m} + for s in "ab" + for m in range(MINUTES) + ] + status, body = request(SENSAPP, "/publish", json.dumps(payload).encode()) + if status != 200: + raise AssertionError(f"Could not seed SensApp: HTTP {status}: {body[:300]}") + + def window(series: str, t: int, seconds: int) -> list[float]: + return [ + hint_value(series, m) + for m in range(MINUTES) + if t - seconds < t0 + 60 * m <= t + ] + + def mean(values: list[float]) -> float: + return sum(values) / len(values) + + def across(function): + # What an aggregation operator sees: the last sample of each series within 5 minutes. + return lambda t: function([window(s, t, 300)[-1] for s in "ab"]) + + def over(function, seconds): + return lambda t: function(window("a", t, seconds)) + + selector = f'{{__name__=~"hints_{tag}_.*"}}' + one = names["a"] + cases = [ + # (expression, step, expected value at the evaluation time, answered in the database?) + (f"avg_over_time({one}[1h])", HOUR, over(mean, HOUR), True), + (f"min_over_time({one}[1h])", HOUR, over(min, HOUR), True), + (f"max_over_time({one}[1h])", HOUR, over(max, HOUR), True), + (f"sum_over_time({one}[1h])", HOUR, over(sum, HOUR), True), + (f"last_over_time({one}[1h])", HOUR, over(lambda w: w[-1], HOUR), True), + (f"avg_over_time({one}[15m])", 900, over(mean, 900), True), + (f"max_over_time({one}[15m])", 900, over(max, 900), True), + # A range that is several steps: the buckets still fit the windows. + (f"max_over_time({one}[2h])", HOUR, over(max, 2 * HOUR), True), + (f"min_over_time({one}[2h])", HOUR, over(min, 2 * HOUR), True), + (f"sum_over_time({one}[2h])", HOUR, over(sum, 2 * HOUR), True), + (f"avg_over_time({one}[2h])", HOUR, over(mean, 2 * HOUR), True), + # Not answered with buckets, or the answer would be wrong: + # a window shorter than the step, a count of samples, aggregations across series. + (f"avg_over_time({one}[30m])", HOUR, over(mean, 1800), False), + (f"max_over_time({one}[10m])", 900, over(max, 600), False), + (f"count_over_time({one}[1h])", HOUR, over(len, HOUR), False), + (f"sum({selector})", HOUR, across(sum), False), + (f"avg({selector})", HOUR, across(mean), False), + (f"min({selector})", HOUR, across(min), False), + (f"max({selector})", HOUR, across(max), False), + (f"count({selector})", HOUR, across(len), False), + (one, HOUR, lambda t: window("a", t, 300)[-1], False), + ] + + failures = [] + for expression, step, expected, _in_database in cases: + # The first evaluation time is chosen so that every window is full of samples. + start, end = t0 + 2 * HOUR, t0 + 5 * HOUR + got = query_range(expression, start, end, step) + for t in range(start, end + 1, step): + want = expected(t) + if t not in got or abs(got[t] - want) > 1e-9 * max(1.0, abs(want)): + failures.append( + f"{expression} step={step}s at T0+{(t - t0) / HOUR:.2f}h: " + f"Prometheus says {got.get(t)}, the raw samples say {want}" + ) + break + if failures: + raise AssertionError("remote read hints give wrong answers:\n " + "\n ".join(failures)) + print(f"PASS remote read hints give the answer of the raw samples ({len(cases)} queries)", flush=True) + + def main() -> None: wait_for("SensApp readiness", lambda: ready(SENSAPP)) wait_for("Prometheus readiness", lambda: ready(PROMETHEUS)) test_remote_write() test_remote_read() + test_remote_read_hints() if __name__ == "__main__":