commit 4e44204d9bdd037151e71af81616615f36b0a392 Author: James Date: Sat Sep 19 02:45:52 2026 +0000 Initial Meterbox commit with boot disk usage metric. Co-authored-by: Cursor diff --git a/.cursor/rules/docker-always-localhost.mdc b/.cursor/rules/docker-always-localhost.mdc new file mode 100644 index 0000000..e9e7901 --- /dev/null +++ b/.cursor/rules/docker-always-localhost.mdc @@ -0,0 +1,13 @@ +--- +description: Meterbox always runs via Docker with localhost-only exposure +alwaysApply: true +--- + +# Meterbox runtime + +- **Always Dockerize** Meterbox. Do not run the native binary as the supported deploy path. +- **Localhost only.** Never publish as `8787:8787` or `0.0.0.0:8787`. +- Preferred compose mode: `network_mode: host` with `METERBOX_LISTEN=127.0.0.1:8787` (no Docker port publish, no DNAT bypass). +- If bridge networking is used instead, publish only `127.0.0.1:8787:8787`. +- After Docker changes: `sudo iptables -L -n -v` and confirm `DOCKER*` chains coexist with the Oracle firewall. +- Caddy/Rathole proxy to `127.0.0.1:8787`; Internet clients must not reach the container directly. diff --git a/.cursor/rules/docker-localhost-ports.mdc b/.cursor/rules/docker-localhost-ports.mdc new file mode 100644 index 0000000..d8b2ae3 --- /dev/null +++ b/.cursor/rules/docker-localhost-ports.mdc @@ -0,0 +1,44 @@ +--- +description: Docker published ports must bind to localhost only on this Oracle host (iptables/Caddy/Rathole) +alwaysApply: true +--- + +# Docker + iptables: localhost-only publishes + +Docker modifies iptables and creates chains such as `DOCKER`, `DOCKER-FORWARD`, and `DOCKER-USER`. Published container ports can bypass some host-firewall assumptions. + +Do **not** assume: + +```bash +docker run -p 8080:8080 ... +``` + +is protected by the host `INPUT REJECT` the way an ordinary host process is. Oracle firewall rules remain, but Docker port publishing is a different path. + +## Hard rule + +Bind **all** Docker published ports to localhost unless the user explicitly requests public exposure: + +```bash +# Required default +docker run -p 127.0.0.1:8080:8080 ... + +# Do not use by default +docker run -p 8080:8080 ... +``` + +Compose equivalent: + +```yaml +ports: + - "127.0.0.1:8080:8080" +``` + +Caddy/Rathole can reach localhost; Internet clients must not hit containers directly. Public ingress stays on intentionally opened host ports (80/443, Rathole, etc.). + +## Checklist + +- Before adding containers with published ports: `sudo iptables -L -n -v` +- Confirm `DOCKER*` chains coexist with the Oracle firewall +- Prefer `DOCKER-USER` for extra Docker firewall policy +- Never publish `0.0.0.0:PORT` / bare `-p HOST:CONTAINER` without explicit user approval diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc24a2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +data +meterbox +backend/target +frontend/node_modules +frontend/dist +**/*.md +.cursor +scripts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7686678 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +data/*.db +data/*.db-* +meterbox +frontend/dist +frontend/node_modules +**/*.swp +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af34047 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM rust:1-bookworm AS backend +WORKDIR /app/backend +COPY backend/Cargo.toml backend/Cargo.lock ./ +RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src +COPY backend/src ./src +RUN touch src/main.rs && cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates vnstat \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=backend /app/backend/target/release/meterbox /usr/local/bin/meterbox +COPY --from=frontend /app/frontend/dist /app/frontend/dist +RUN mkdir -p /app/data +ENV METERBOX_LISTEN=127.0.0.1:8787 \ + METERBOX_DB=/app/data/meterbox.db \ + METERBOX_STATIC=/app/frontend/dist \ + METERBOX_INTERVAL_SECS=30 \ + METERBOX_RETENTION_DAYS=90 \ + METERBOX_BWFLOW_SOCK=/run/bwflow/bwflow.sock +EXPOSE 8787 +VOLUME ["/app/data"] +CMD ["meterbox"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ee1b75 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Meterbox + +Reusable host resource dashboard. **Always run via Docker**, bound to localhost only. + +Rust collector + API, React charts for **CPU**, **memory**, and **network**. + +| Range | Bucket | +|-------|--------| +| 24 hours | 1 minute | +| 7 days | 5 minutes | +| 4 weeks | 30 minutes | +| 3 months | 2 hours | + +## Install / start (Docker only) + +```bash +cd /workspace/meterbox +./scripts/up.sh +# → http://127.0.0.1:8787 +``` + +Stop: + +```bash +./scripts/down.sh +``` + +Compose uses `network_mode: host` and `METERBOX_LISTEN=127.0.0.1:8787`: + +- No Docker port publish (`8787:8787` never used) +- Does not DNAT around the host `INPUT REJECT` +- Caddy/Rathole can proxy to `127.0.0.1:8787` +- Host PID namespace for real host load/CPU visibility + +**Never** change the listen address to `0.0.0.0` or publish `0.0.0.0:8787` unless you explicitly want the dashboard on the public Internet. + +## Environment + +| Variable | Default (compose) | Meaning | +|----------|-------------------|---------| +| `METERBOX_LISTEN` | `127.0.0.1:8787` | Bind address (keep localhost) | +| `METERBOX_DB` | `/app/data/meterbox.db` | SQLite path | +| `METERBOX_STATIC` | `/app/frontend/dist` | Built UI | +| `METERBOX_INTERVAL_SECS` | `30` | Sample period | +| `METERBOX_RETENTION_DAYS` | `90` | Drop samples older than this | +| `METERBOX_MAX_DB_BYTES` | `10737418240` (10 GiB) | Hard on-disk SQLite size cap | +| `METERBOX_CLEANUP_INTERVAL_SECS` | `3600` | Periodic cleanup job cadence | + +### SQLite cleanup job + +A background job runs on startup (after ~15s) and every `METERBOX_CLEANUP_INTERVAL_SECS`: + +1. Deletes samples older than `METERBOX_RETENTION_DAYS` +2. If on-disk size (`.db` + `-wal` + `-shm`) exceeds **10 GiB**, deletes oldest rows in batches until under ~90% of the cap +3. Checkpoints WAL / `VACUUM`s when needed so deleted space is reclaimed + +An emergency size check also runs after each sample insert if the hard cap is breached. + +`GET /api/health` reports `db_bytes`, `max_db_bytes`, and retention settings. + +## API + +- `GET /api/health` +- `GET /api/host` +- `GET /api/snapshot` +- `GET /api/series?range=24h|7d|4w|3m` + +## Install on another server + +1. Copy/clone this repo. +2. `./scripts/up.sh` +3. Reverse-proxy `127.0.0.1:8787` with Caddy/Rathole. +4. Confirm with `sudo iptables -L -n -v` that Docker chains coexist safely with your host firewall. diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..421f4dc --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,2409 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "meterbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "chrono", + "clap", + "hex", + "hostname", + "libc", + "password-hash", + "rand 0.8.8", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "sysinfo", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..07fe60b --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "meterbox" +version = "0.1.0" +edition = "2021" +description = "Reusable host resource dashboard — collects and charts CPU, memory, and network usage" +license = "MIT" + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["fs", "cors", "trace", "compression-gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde", "clock"] } +rusqlite = { version = "0.32", features = ["bundled"] } +sysinfo = "0.33" +libc = "0.2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +thiserror = "2" +hostname = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli"] } +argon2 = "0.5" +password-hash = "0.5" +rand = "0.8" +sha2 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4"] } + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/backend/src/api.rs b/backend/src/api.rs new file mode 100644 index 0000000..e2f79e5 --- /dev/null +++ b/backend/src/api.rs @@ -0,0 +1,789 @@ +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use crate::auth::UserPublic; +use crate::health::{HealthSite, SiteHistory, UpsertSite}; +use crate::metrics::{HostInfo, Sample, Sampler}; +use crate::probe::{probe_url, ProbeResult}; +use crate::settings::{AppSettings, PublicAccess}; +use crate::AppState; + +const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60; + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/host", get(host)) + .route("/snapshot", get(snapshot)) + .route("/series", get(series)) + .route("/compliance", get(compliance)) + .route("/bwflow", get(bwflow_stats)) + .route("/auth/status", get(auth_status)) + .route("/auth/setup", post(auth_setup)) + .route("/auth/login", post(auth_login)) + .route("/auth/logout", post(auth_logout)) + .route("/auth/me", get(auth_me)) + .route("/sites", get(list_sites).post(create_site)) + .route("/sites/probe", post(probe_site)) + .route("/sites/{id}", get(get_site).put(update_site).delete(delete_site)) + .route("/sites/{id}/check", post(check_site_now)) + .route("/sites/{id}/history", get(site_history)) + .route("/settings", get(get_settings).put(put_settings)) + .with_state(state) +} + +#[derive(Serialize)] +struct Health { + ok: bool, + service: &'static str, + samples: i64, + oldest_ts: Option, + db_bytes: u64, + max_db_bytes: u64, + retention_days: i64, + cleanup_interval_secs: u64, +} + +async fn health(State(state): State) -> Json { + Json(Health { + ok: true, + service: "meterbox", + samples: state.db.count().unwrap_or(0), + oldest_ts: state.db.oldest_ts().ok().flatten(), + db_bytes: state.db.on_disk_bytes().unwrap_or(0), + max_db_bytes: state.config.max_db_bytes, + retention_days: state.config.retention_days, + cleanup_interval_secs: state.config.cleanup_interval_secs, + }) +} + +async fn host( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + require_dashboard_read(&state, &headers)?; + let mut sampler = Sampler::new(); + Ok(Json(sampler.host_info())) +} + +async fn snapshot( + State(state): State, + headers: HeaderMap, +) -> Result>, StatusCode> { + require_dashboard_read(&state, &headers)?; + Ok(Json(state.db.latest().ok().flatten())) +} + +async fn compliance( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + require_dashboard_read(&state, &headers)?; + let mut sampler = Sampler::new(); + let host = sampler.host_info(); + let bw = crate::bwflow::fetch(&crate::bwflow::socket_from_env()).await; + let bw_tx = if bw.available { bw.tx_bytes } else { None }; + Ok(Json(crate::compliance::evaluate( + &state.db, + &host, + state.config.interval_secs, + bw_tx, + ))) +} + +async fn bwflow_stats( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + require_dashboard_read(&state, &headers)?; + let sock = crate::bwflow::socket_from_env(); + Ok(Json(crate::bwflow::fetch(&sock).await)) +} + +#[derive(Debug, Deserialize)] +struct SeriesQuery { + /// 24h | 7d | 4w | 3m + range: String, +} + +#[derive(Serialize)] +struct SeriesPoint { + ts: i64, + cpu_pct: f32, + mem_pct: f32, + mem_used_bytes: u64, + mem_total_bytes: u64, + disk_pct: f32, + disk_used_bytes: u64, + disk_total_bytes: u64, + net_rx_bps: f64, + net_tx_bps: f64, + load1: f64, +} + +#[derive(Serialize)] +struct SeriesResponse { + range: String, + from_ts: i64, + to_ts: i64, + bucket_secs: i64, + points: Vec, + averages: Averages, +} + +#[derive(Serialize)] +struct Averages { + cpu_pct: f32, + mem_pct: f32, + disk_pct: f32, + net_rx_bps: f64, + net_tx_bps: f64, + load1: f64, +} + +fn range_window(range: &str) -> Option<(i64, i64)> { + let secs = match range { + "24h" => 24 * 3600, + "7d" => 7 * 24 * 3600, + "4w" => 28 * 24 * 3600, + "3m" => 90 * 24 * 3600, + _ => return None, + }; + let bucket = match range { + "24h" => 60, // 1 min + "7d" => 5 * 60, // 5 min + "4w" => 30 * 60, // 30 min + "3m" => 2 * 3600, // 2 hours + _ => 60, + }; + Some((secs, bucket)) +} + +fn downsample(samples: &[Sample], bucket_secs: i64) -> Vec { + if samples.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + let mut bucket_start = samples[0].ts - (samples[0].ts % bucket_secs); + + let mut cpu = 0.0f64; + let mut mem = 0.0f64; + let mut mem_used = 0u64; + let mut mem_total = 0u64; + let mut disk = 0.0f64; + let mut disk_used = 0u64; + let mut disk_total = 0u64; + let mut rx = 0.0f64; + let mut tx = 0.0f64; + let mut load = 0.0f64; + let mut n = 0u64; + + let flush = |out: &mut Vec, + start: i64, + cpu: f64, + mem: f64, + mem_used: u64, + mem_total: u64, + disk: f64, + disk_used: u64, + disk_total: u64, + rx: f64, + tx: f64, + load: f64, + n: u64| { + if n == 0 { + return; + } + let nf = n as f64; + out.push(SeriesPoint { + ts: start, + cpu_pct: (cpu / nf) as f32, + mem_pct: (mem / nf) as f32, + mem_used_bytes: mem_used / n, + mem_total_bytes: mem_total / n, + disk_pct: (disk / nf) as f32, + disk_used_bytes: disk_used / n, + disk_total_bytes: disk_total / n, + net_rx_bps: rx / nf, + net_tx_bps: tx / nf, + load1: load / nf, + }); + }; + + for s in samples { + let b = s.ts - (s.ts % bucket_secs); + if b != bucket_start { + flush( + &mut out, + bucket_start, + cpu, + mem, + mem_used, + mem_total, + disk, + disk_used, + disk_total, + rx, + tx, + load, + n, + ); + bucket_start = b; + cpu = 0.0; + mem = 0.0; + mem_used = 0; + mem_total = 0; + disk = 0.0; + disk_used = 0; + disk_total = 0; + rx = 0.0; + tx = 0.0; + load = 0.0; + n = 0; + } + cpu += s.cpu_pct as f64; + mem += s.mem_pct as f64; + mem_used += s.mem_used_bytes; + mem_total += s.mem_total_bytes; + disk += s.disk_pct as f64; + disk_used += s.disk_used_bytes; + disk_total += s.disk_total_bytes; + rx += s.net_rx_bps; + tx += s.net_tx_bps; + load += s.load1; + n += 1; + } + flush( + &mut out, + bucket_start, + cpu, + mem, + mem_used, + mem_total, + disk, + disk_used, + disk_total, + rx, + tx, + load, + n, + ); + out +} + +fn compute_averages(points: &[SeriesPoint]) -> Averages { + if points.is_empty() { + return Averages { + cpu_pct: 0.0, + mem_pct: 0.0, + disk_pct: 0.0, + net_rx_bps: 0.0, + net_tx_bps: 0.0, + load1: 0.0, + }; + } + let n = points.len() as f64; + Averages { + cpu_pct: (points.iter().map(|p| p.cpu_pct as f64).sum::() / n) as f32, + mem_pct: (points.iter().map(|p| p.mem_pct as f64).sum::() / n) as f32, + disk_pct: (points.iter().map(|p| p.disk_pct as f64).sum::() / n) as f32, + net_rx_bps: points.iter().map(|p| p.net_rx_bps).sum::() / n, + net_tx_bps: points.iter().map(|p| p.net_tx_bps).sum::() / n, + load1: points.iter().map(|p| p.load1).sum::() / n, + } +} + +async fn series( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Result, StatusCode> { + require_dashboard_read(&state, &headers)?; + let (window, bucket) = range_window(q.range.as_str()).ok_or(StatusCode::BAD_REQUEST)?; + let to_ts = chrono::Utc::now().timestamp(); + let from_ts = to_ts - window; + let samples = state + .db + .query_range(from_ts, to_ts) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let points = downsample(&samples, bucket); + let averages = compute_averages(&points); + Ok(Json(SeriesResponse { + range: q.range, + from_ts, + to_ts, + bucket_secs: bucket, + points, + averages, + })) +} + +// --- Auth (one-time admin setup + login for interactive dashboard) ---------- + +#[derive(Serialize)] +struct AuthStatus { + needs_setup: bool, + user: Option, + public: PublicAccess, +} + +#[derive(Deserialize)] +struct Credentials { + email: String, + password: String, +} + +#[derive(Serialize)] +struct AuthSession { + token: String, + user: UserPublic, +} + +#[derive(Serialize)] +struct ApiError { + error: String, +} + +fn bearer_token(headers: &HeaderMap) -> Option { + let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; + raw.strip_prefix("Bearer ") + .or_else(|| raw.strip_prefix("bearer ")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn optional_user(state: &AppState, headers: &HeaderMap) -> Result, StatusCode> { + let Some(token) = bearer_token(headers) else { + return Ok(None); + }; + state + .db + .user_from_token(&token) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn require_user(state: &AppState, headers: &HeaderMap) -> Result { + optional_user(state, headers)?.ok_or(StatusCode::UNAUTHORIZED) +} + +fn load_settings(state: &AppState) -> Result { + state + .db + .get_settings() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn require_dashboard_read(state: &AppState, headers: &HeaderMap) -> Result<(), StatusCode> { + if optional_user(state, headers)?.is_some() { + return Ok(()); + } + if load_settings(state)?.public_dashboard { + return Ok(()); + } + Err(StatusCode::UNAUTHORIZED) +} + +fn require_site_read(state: &AppState, headers: &HeaderMap, site_id: i64) -> Result<(), StatusCode> { + if optional_user(state, headers)?.is_some() { + return Ok(()); + } + if load_settings(state)?.site_is_public(site_id) { + return Ok(()); + } + Err(StatusCode::UNAUTHORIZED) +} + +async fn auth_status( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + let needs_setup = state + .db + .user_count() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + == 0; + let user = optional_user(&state, &headers)?; + let public = load_settings(&state)?.public_access(); + Ok(Json(AuthStatus { + needs_setup, + user, + public, + })) +} + +async fn auth_setup( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let user = state + .db + .create_admin(&body.email, &body.password) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("already exists") { + ( + StatusCode::CONFLICT, + Json(ApiError { + error: "setup already completed".into(), + }), + ) + } else { + bad_request(msg) + } + })?; + let token = state + .db + .create_session(user.id, SESSION_TTL_SECS) + .map_err(internal_err)?; + Ok(Json(AuthSession { token, user })) +} + +async fn auth_login( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let user = state + .db + .authenticate(&body.email, &body.password) + .map_err(internal_err)? + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(ApiError { + error: "invalid email or password".into(), + }), + ) + })?; + let token = state + .db + .create_session(user.id, SESSION_TTL_SECS) + .map_err(internal_err)?; + Ok(Json(AuthSession { token, user })) +} + +async fn auth_logout( + State(state): State, + headers: HeaderMap, +) -> Result { + if let Some(token) = bearer_token(&headers) { + let _ = state.db.delete_session(&token); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn auth_me( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + Ok(Json(require_user(&state, &headers)?)) +} + +async fn get_settings( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + let _user = require_user(&state, &headers)?; + Ok(Json(load_settings(&state)?)) +} + +async fn put_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let _user = require_user(&state, &headers).map_err(|_| unauthorized())?; + let saved = state.db.save_settings(&body).map_err(internal_err)?; + if saved.cleanup_logs_older_than_3_months { + if let Err(e) = state + .db + .prune_health_results_older_than(crate::settings::HEALTH_LOG_RETENTION_DAYS) + { + tracing::warn!(error = %e, "immediate health log prune failed"); + } + } + Ok(Json(saved)) +} + +fn bad_request(msg: String) -> (StatusCode, Json) { + (StatusCode::BAD_REQUEST, Json(ApiError { error: msg })) +} + +fn internal_err(e: anyhow::Error) -> (StatusCode, Json) { + tracing::error!(error = %e, "internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "internal error".into(), + }), + ) +} + +// --- Site health checks ----------------------------------------------------- + +#[derive(Deserialize)] +struct ProbeRequest { + url: String, + expected_status: Option, + expect_body_contains: Option, + timeout_ms: Option, +} + +#[derive(Serialize)] +struct SiteCreateResponse { + site: HealthSite, + probe: ProbeResult, +} + +fn unauthorized() -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(ApiError { + error: "unauthorized".into(), + }), + ) +} + +async fn list_sites( + State(state): State, + headers: HeaderMap, +) -> Result>, StatusCode> { + let user = optional_user(&state, &headers)?; + let settings = load_settings(&state)?; + let sites = state + .db + .list_health_sites() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if user.is_some() { + return Ok(Json(sites)); + } + if settings.public_site_ids.is_empty() { + return Err(StatusCode::UNAUTHORIZED); + } + let public: Vec<_> = sites + .into_iter() + .filter(|s| settings.site_is_public(s.id)) + .collect(); + Ok(Json(public)) +} + +async fn get_site( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, StatusCode> { + require_site_read(&state, &headers, id)?; + state + .db + .get_health_site(id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +#[derive(Deserialize)] +struct HistoryQuery { + range: Option, +} + +async fn site_history( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Query(q): Query, +) -> Result, StatusCode> { + require_site_read(&state, &headers, id)?; + let range = q.range.as_deref().unwrap_or("24h"); + state + .db + .site_history(id, range) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +async fn probe_site( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, StatusCode> { + let _user = require_user(&state, &headers)?; + let expected = body.expected_status.unwrap_or(200); + let timeout = body.timeout_ms.unwrap_or(10_000); + let expect = body + .expect_body_contains + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + Ok(Json( + probe_url(&body.url, expected, expect, timeout).await, + )) +} + +async fn create_site( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let _user = require_user(&state, &headers).map_err(|_| unauthorized())?; + + let expected = body.expected_status.unwrap_or(200) as u16; + let timeout = body.timeout_ms.unwrap_or(10_000) as u64; + let expect = body + .expect_body_contains + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let probe = probe_url(&body.url, expected, expect.as_deref(), timeout).await; + if !probe.ok { + let detail = if probe.cloudflare_fallback || probe.cloudflare_challenge { + format!( + "URL failed Cloudflare/origin validation: {}", + probe.reasons.join("; ") + ) + } else { + format!("URL health probe failed: {}", probe.reasons.join("; ")) + }; + return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(ApiError { error: detail }))); + } + + let site = state + .db + .insert_health_site(&body) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("UNIQUE") || msg.contains("unique") { + bad_request("a site with this URL already exists".into()) + } else { + bad_request(msg) + } + })?; + + if let Err(e) = state.db.record_health_result(site.id, &probe) { + tracing::warn!(error = %e, "failed to store initial probe result"); + } + let site = state + .db + .get_health_site(site.id) + .map_err(internal_err)? + .unwrap_or(site); + + Ok(Json(SiteCreateResponse { site, probe })) +} + +async fn update_site( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let _user = require_user(&state, &headers).map_err(|_| unauthorized())?; + + let expected = body.expected_status.unwrap_or(200) as u16; + let timeout = body.timeout_ms.unwrap_or(10_000) as u64; + let expect = body + .expect_body_contains + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let probe = probe_url(&body.url, expected, expect.as_deref(), timeout).await; + if !probe.ok { + let detail = if probe.cloudflare_fallback || probe.cloudflare_challenge { + format!( + "URL failed Cloudflare/origin validation: {}", + probe.reasons.join("; ") + ) + } else { + format!("URL health probe failed: {}", probe.reasons.join("; ")) + }; + return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(ApiError { error: detail }))); + } + + let site = state + .db + .update_health_site(id, &body) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("not found") { + ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "site not found".into(), + }), + ) + } else if msg.contains("UNIQUE") || msg.contains("unique") { + bad_request("a site with this URL already exists".into()) + } else { + bad_request(msg) + } + })?; + + let _ = state.db.record_health_result(site.id, &probe); + let site = state + .db + .get_health_site(site.id) + .map_err(internal_err)? + .unwrap_or(site); + + Ok(Json(SiteCreateResponse { site, probe })) +} + +async fn delete_site( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let _user = require_user(&state, &headers)?; + let deleted = state + .db + .delete_health_site(id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +async fn check_site_now( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let _user = require_user(&state, &headers).map_err(|_| unauthorized())?; + let site = state + .db + .get_health_site(id) + .map_err(internal_err)? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "site not found".into(), + }), + ) + })?; + + let expect = site.expect_body_contains.clone(); + let probe = probe_url( + &site.url, + site.expected_status as u16, + expect.as_deref(), + site.timeout_ms as u64, + ) + .await; + state + .db + .record_health_result(site.id, &probe) + .map_err(internal_err)?; + let site = state + .db + .get_health_site(site.id) + .map_err(internal_err)? + .unwrap_or(site); + Ok(Json(SiteCreateResponse { site, probe })) +} diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..efb1825 --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,188 @@ +//! Single local admin account + session tokens (SQLite-backed). +//! +//! First-run setup creates the only admin (email + password). After that, +//! interactive dashboard use requires login. + +use anyhow::{bail, Context, Result}; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +use crate::db::Db; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UserPublic { + pub id: i64, + pub email: String, + pub created_at: i64, +} + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("hash password: {e}"))? + .to_string(); + Ok(hash) +} + +pub fn verify_password(password: &str, password_hash: &str) -> Result { + let parsed = PasswordHash::new(password_hash) + .map_err(|e| anyhow::anyhow!("parse hash: {e}"))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn new_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn normalize_email(email: &str) -> Result { + let email = email.trim().to_lowercase(); + if email.len() < 5 || !email.contains('@') || !email.contains('.') { + bail!("enter a valid email address"); + } + let (local, domain) = email + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("enter a valid email address"))?; + if local.is_empty() || domain.is_empty() || !domain.contains('.') { + bail!("enter a valid email address"); + } + Ok(email) +} + +impl Db { + pub fn user_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?; + Ok(n) + } + + /// Create the single admin account. Email is stored in the `username` column. + pub fn create_admin(&self, email: &str, password: &str) -> Result { + if self.user_count()? > 0 { + bail!("admin account already exists"); + } + let email = normalize_email(email)?; + if password.len() < 8 { + bail!("password must be at least 8 characters"); + } + let hash = hash_password(password)?; + let now = chrono::Utc::now().timestamp(); + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO users (username, password_hash, created_at) VALUES (?1, ?2, ?3)", + rusqlite::params![email, hash, now], + ) + .context("create admin")?; + let id = conn.last_insert_rowid(); + Ok(UserPublic { + id, + email, + created_at: now, + }) + } + + pub fn authenticate(&self, email: &str, password: &str) -> Result> { + let email = normalize_email(email)?; + let conn = self.conn.lock().unwrap(); + let row = conn + .query_row( + "SELECT id, username, password_hash, created_at FROM users WHERE username = ?1", + rusqlite::params![email], + |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, i64>(3)?, + )) + }, + ) + .optional()?; + let Some((id, email, hash, created_at)) = row else { + return Ok(None); + }; + drop(conn); + if !verify_password(password, &hash)? { + return Ok(None); + } + Ok(Some(UserPublic { + id, + email, + created_at, + })) + } + + pub fn create_session(&self, user_id: i64, ttl_secs: i64) -> Result { + let token = new_token(); + let token_hash = hash_token(&token); + let now = chrono::Utc::now().timestamp(); + let expires = now + ttl_secs; + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![token_hash, user_id, now, expires], + )?; + Ok(token) + } + + pub fn user_from_token(&self, token: &str) -> Result> { + if token.is_empty() { + return Ok(None); + } + let token_hash = hash_token(token); + let now = chrono::Utc::now().timestamp(); + let conn = self.conn.lock().unwrap(); + let row = conn + .query_row( + "SELECT u.id, u.username, u.created_at + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?1 AND s.expires_at > ?2", + rusqlite::params![token_hash, now], + |r| { + Ok(UserPublic { + id: r.get(0)?, + email: r.get(1)?, + created_at: r.get(2)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + pub fn delete_session(&self, token: &str) -> Result<()> { + let token_hash = hash_token(token); + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM sessions WHERE token_hash = ?1", + rusqlite::params![token_hash], + )?; + Ok(()) + } + + pub fn prune_expired_sessions(&self) -> Result { + let now = chrono::Utc::now().timestamp(); + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "DELETE FROM sessions WHERE expires_at <= ?1", + rusqlite::params![now], + )?; + Ok(n) + } +} + +use rusqlite::OptionalExtension; diff --git a/backend/src/bwflow.rs b/backend/src/bwflow.rs new file mode 100644 index 0000000..61819f1 --- /dev/null +++ b/backend/src/bwflow.rs @@ -0,0 +1,252 @@ +//! Client for the bwflow daemon Unix socket (`/run/bwflow/bwflow.sock`). +//! +//! Protocol: send `status\n`, receive one NDJSON object. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +pub const DEFAULT_SOCKET: &str = "/run/bwflow/bwflow.sock"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BwflowRawStatus { + pub state: String, + pub tx_bytes: u64, + pub tokens_bytes: i64, + pub throttle_kbps: u64, + pub monthly_budget_gb: u64, + pub pre_charge_gb: u64, + pub interface: String, + pub as_of: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MonthTraffic { + pub year: i32, + pub month: u32, + pub rx_bytes: u64, + pub tx_bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BwflowStats { + pub available: bool, + pub error: Option, + pub socket: String, + pub state: Option, + pub interface: Option, + pub as_of: Option, + pub tx_bytes: Option, + pub tokens_bytes: Option, + pub throttle_kbps: Option, + pub monthly_budget_gb: Option, + pub pre_charge_gb: Option, + /// 0–100 of monthly budget consumed + pub budget_used_pct: Option, + pub tokens_remaining_gb: Option, + pub tx_gb: Option, + pub rate_limit_on: Option, + /// Calendar-month period label, e.g. "September 2026" + pub calendar_month: Option, + /// Inclusive start of the Oracle calendar-month window (UTC ISO date) + pub period_start: Option, + /// Exclusive end / reset date of the window (1st of next month, UTC) + pub period_resets: Option, + pub months: Vec, +} + +impl BwflowStats { + pub fn unavailable(socket: impl Into, error: impl Into) -> Self { + let (calendar_month, period_start, period_resets) = calendar_period_labels(); + Self { + available: false, + error: Some(error.into()), + socket: socket.into(), + state: None, + interface: None, + as_of: None, + tx_bytes: None, + tokens_bytes: None, + throttle_kbps: None, + monthly_budget_gb: None, + pre_charge_gb: None, + budget_used_pct: None, + tokens_remaining_gb: None, + tx_gb: None, + rate_limit_on: None, + calendar_month: Some(calendar_month), + period_start: Some(period_start), + period_resets: Some(period_resets), + months: Vec::new(), + } + } +} + +const GB: f64 = 1_000_000_000.0; + +fn calendar_period_labels() -> (String, String, String) { + use chrono::{Datelike, Duration, TimeZone, Utc}; + let now = Utc::now(); + let start = Utc + .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .single() + .unwrap_or(now); + let resets = if now.month() == 12 { + Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 0, 0, 0) + .single() + .unwrap_or(start + Duration::days(31)) + } else { + Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 0, 0, 0) + .single() + .unwrap_or(start + Duration::days(31)) + }; + let label = start.format("%B %Y").to_string(); + ( + label, + start.format("%Y-%m-%d").to_string(), + resets.format("%Y-%m-%d").to_string(), + ) +} + +pub async fn fetch(socket: &Path) -> BwflowStats { + let sock = socket.display().to_string(); + let (calendar_month, period_start, period_resets) = calendar_period_labels(); + match query_status(socket).await { + Ok(raw) => { + let tx_gb = raw.tx_bytes as f64 / GB; + let budget_gb = raw.monthly_budget_gb as f64; + let used_pct = if budget_gb > 0.0 { + Some((tx_gb / budget_gb * 100.0).min(100.0)) + } else { + None + }; + let iface = raw.interface.clone(); + let months = tokio::task::spawn_blocking(move || fetch_vnstat_months(&iface)) + .await + .unwrap_or_else(|_| Vec::new()); + + BwflowStats { + available: true, + error: None, + socket: sock, + state: Some(raw.state.clone()), + interface: Some(raw.interface), + as_of: Some(raw.as_of), + tx_bytes: Some(raw.tx_bytes), + tokens_bytes: Some(raw.tokens_bytes), + throttle_kbps: Some(raw.throttle_kbps), + monthly_budget_gb: Some(raw.monthly_budget_gb), + pre_charge_gb: Some(raw.pre_charge_gb), + budget_used_pct: used_pct, + tokens_remaining_gb: Some(raw.tokens_bytes as f64 / GB), + tx_gb: Some(tx_gb), + rate_limit_on: Some(raw.state == "throttled"), + calendar_month: Some(calendar_month), + period_start: Some(period_start), + period_resets: Some(period_resets), + months, + } + } + Err(e) => BwflowStats::unavailable(sock, format!("{e:#}")), + } +} + +async fn query_status(socket: &Path) -> Result { + if !socket.exists() { + anyhow::bail!("socket not found at {}", socket.display()); + } + let stream = tokio::time::timeout(Duration::from_secs(2), UnixStream::connect(socket)) + .await + .context("connect timeout")? + .with_context(|| format!("connect {}", socket.display()))?; + + let (reader, mut writer) = stream.into_split(); + writer.write_all(b"status\n").await?; + writer.shutdown().await.ok(); + + let mut lines = BufReader::new(reader).lines(); + let line = tokio::time::timeout(Duration::from_secs(2), lines.next_line()) + .await + .context("read timeout")? + .context("read line")? + .context("empty response from bwflow")?; + + let raw: BwflowRawStatus = + serde_json::from_str(&line).with_context(|| format!("parse bwflow json: {line}"))?; + Ok(raw) +} + +fn fetch_vnstat_months(interface: &str) -> Vec { + let output = Command::new("vnstat") + .args(["--json", "m", "-i", interface]) + .output(); + let Ok(out) = output else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + let Ok(v) = serde_json::from_slice::(&out.stdout) else { + return Vec::new(); + }; + let mut months = Vec::new(); + let Some(ifaces) = v.get("interfaces").and_then(|x| x.as_array()) else { + return months; + }; + for iface in ifaces { + let name = iface.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name != interface && !interface.is_empty() { + // Prefer matching interface; if only one, still take it below. + } + let Some(arr) = iface + .pointer("/traffic/month") + .and_then(|m| m.as_array()) + else { + continue; + }; + let take = name == interface || ifaces.len() == 1; + if !take { + continue; + } + for m in arr { + let year = m + .pointer("/date/year") + .and_then(|x| x.as_i64()) + .unwrap_or(0) as i32; + let month = m + .pointer("/date/month") + .and_then(|x| x.as_u64()) + .unwrap_or(0) as u32; + let rx = m.get("rx").and_then(|x| x.as_u64()).unwrap_or(0); + let tx = m.get("tx").and_then(|x| x.as_u64()).unwrap_or(0); + if year > 0 && month > 0 { + months.push(MonthTraffic { + year, + month, + rx_bytes: rx, + tx_bytes: tx, + }); + } + } + if name == interface { + break; + } + } + // Keep last 6 months + if months.len() > 6 { + let skip = months.len() - 6; + months = months.into_iter().skip(skip).collect(); + } + months +} + +pub fn socket_from_env() -> PathBuf { + std::env::var("METERBOX_BWFLOW_SOCK") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_SOCKET)) +} diff --git a/backend/src/cleanup.rs b/backend/src/cleanup.rs new file mode 100644 index 0000000..565932b --- /dev/null +++ b/backend/src/cleanup.rs @@ -0,0 +1,64 @@ +use std::time::Duration; + +use crate::AppState; + +/// Background job: age retention + hard SQLite size cap (default 10 GiB). +pub fn spawn(state: AppState) { + tokio::spawn(async move { + // Run once shortly after boot, then on the configured cadence. + tokio::time::sleep(Duration::from_secs(15)).await; + run_once(&state); + + let secs = state.config.cleanup_interval_secs.max(60); + let mut ticker = tokio::time::interval(Duration::from_secs(secs)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Skip the immediate first tick (already ran above). + ticker.tick().await; + + loop { + ticker.tick().await; + run_once(&state); + } + }); +} + +fn run_once(state: &AppState) { + let max = state.config.max_db_bytes; + // Aim for 90% of the cap so we don't thrash at the ceiling. + let target = (max as f64 * 0.90) as u64; + match state + .db + .run_cleanup(state.config.retention_days, max, target) + { + Ok(report) => { + tracing::info!( + deleted_by_age = report.deleted_by_age, + deleted_by_size = report.deleted_by_size, + vacuumed = report.vacuumed, + bytes_before = report.bytes_before, + bytes_after = report.bytes_after, + rows = report.rows_after, + max_bytes = max, + "sqlite cleanup finished" + ); + } + Err(e) => tracing::warn!(error = %e, "sqlite cleanup failed"), + } + + match state.db.get_settings() { + Ok(settings) if settings.cleanup_logs_older_than_3_months => { + match state + .db + .prune_health_results_older_than(crate::settings::HEALTH_LOG_RETENTION_DAYS) + { + Ok(n) if n > 0 => { + tracing::info!(deleted = n, "pruned health logs older than 3 months") + } + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "health log prune failed"), + } + } + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "load settings for cleanup failed"), + } +} diff --git a/backend/src/collector.rs b/backend/src/collector.rs new file mode 100644 index 0000000..e9ffaf8 --- /dev/null +++ b/backend/src/collector.rs @@ -0,0 +1,43 @@ +use std::time::Duration; + +use crate::AppState; +use crate::metrics::Sampler; + +pub fn spawn(state: AppState) { + tokio::spawn(async move { + let mut sampler = Sampler::new(); + // Prime CPU & network counters + let _ = sampler.sample(); + tokio::time::sleep(Duration::from_secs(1)).await; + + let interval = Duration::from_secs(state.config.interval_secs.max(5)); + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + ticker.tick().await; + let sample = sampler.sample(); + if let Err(e) = state.db.insert_sample(&sample) { + tracing::error!(error = %e, "failed to insert sample"); + continue; + } + + // Emergency size guard: if a write pushed us over the hard cap, trim ASAP. + match state.db.on_disk_bytes() { + Ok(bytes) if bytes > state.config.max_db_bytes => { + tracing::warn!(bytes, max = state.config.max_db_bytes, "db over size cap; emergency cleanup"); + let target = (state.config.max_db_bytes as f64 * 0.90) as u64; + if let Err(e) = state.db.run_cleanup( + state.config.retention_days, + state.config.max_db_bytes, + target, + ) { + tracing::error!(error = %e, "emergency cleanup failed"); + } + } + Ok(_) => {} + Err(e) => tracing::debug!(error = %e, "could not stat db size"), + } + } + }); +} diff --git a/backend/src/compliance.rs b/backend/src/compliance.rs new file mode 100644 index 0000000..0996382 --- /dev/null +++ b/backend/src/compliance.rs @@ -0,0 +1,355 @@ +//! Host capacity / budget targets for this instance. +//! +//! Operational ceilings we track against local stats: +//! - ≤ 2 OCPUs and ≤ 12 GB RAM +//! - ≤ 200 GB root/block volume +//! - ≤ 10 TB outbound data transfer per calendar month +//! +//! Activity floor (7-day window) — warn when all are simultaneously low: +//! - CPU 95th percentile < 20% +//! - Network utilization < 20% of shape bandwidth +//! - Memory utilization < 20% + +use serde::Serialize; + +use crate::db::Db; +use crate::metrics::{boot_disk_usage, HostInfo, Sample}; + +pub const TARGET_OCPU_LIMIT: f64 = 2.0; +pub const TARGET_MEM_BYTES: u64 = 12 * 1024 * 1024 * 1024; +pub const TARGET_BLOCK_BYTES: u64 = 200 * 1024 * 1024 * 1024; +pub const TARGET_EGRESS_BYTES_MONTH: u64 = 10_000_000_000_000; // 10 TB / calendar month + +pub const IDLE_CPU_P95_PCT: f32 = 20.0; +pub const IDLE_MEM_PCT: f32 = 20.0; +pub const IDLE_NET_UTIL_PCT: f32 = 20.0; + +fn shape_bandwidth_bps(ocpus: f64) -> f64 { + ocpus.max(0.125) * 1_000_000_000.0 / 8.0 +} + +#[derive(Debug, Clone, Serialize)] +pub struct Check { + pub id: String, + pub label: String, + pub status: String, // "pass" | "fail" | "warn" | "info" + pub detail: String, + pub current: String, + pub limit: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComplianceReport { + pub overall: String, // "compliant" | "at_risk" | "over_limit" + pub summary: String, + pub tier: String, + pub docs_url: String, + pub notes: Vec, + pub checks: Vec, + pub host: HostSnapshot, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HostSnapshot { + pub ocpus: f64, + pub mem_total_bytes: u64, + pub disk_total_bytes: u64, + pub arch: String, + pub is_arm: bool, +} + +pub fn disk_total_bytes() -> u64 { + boot_disk_usage().total_bytes +} + +fn percentile_95(values: &mut [f32]) -> Option { + if values.is_empty() { + return None; + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let idx = ((values.len() as f64 - 1.0) * 0.95).round() as usize; + Some(values[idx.min(values.len() - 1)]) +} + +fn avg_f32(values: &[f32]) -> Option { + if values.is_empty() { + return None; + } + Some(values.iter().map(|v| *v as f64).sum::() as f32 / values.len() as f32) +} + +fn avg_f64(values: &[f64]) -> Option { + if values.is_empty() { + return None; + } + Some(values.iter().sum::() / values.len() as f64) +} + +fn format_bytes(n: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + const TIB: f64 = GIB * 1024.0; + let x = n as f64; + if x >= TIB { + format!("{:.2} TiB", x / TIB) + } else if x >= GIB { + format!("{:.2} GiB", x / GIB) + } else if x >= MIB { + format!("{:.1} MiB", x / MIB) + } else { + format!("{n} B") + } +} + +fn month_start_ts() -> i64 { + use chrono::{Datelike, TimeZone, Utc}; + let now = Utc::now(); + Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .single() + .map(|d| d.timestamp()) + .unwrap_or(now.timestamp()) +} + +fn estimate_month_egress(samples: &[Sample], interval_hint_secs: f64) -> u64 { + if samples.len() < 2 { + return 0; + } + let mut total = 0.0f64; + for w in samples.windows(2) { + let dt = (w[1].ts - w[0].ts).max(1) as f64; + let dt = if dt > 3600.0 { interval_hint_secs } else { dt }; + total += w[1].net_tx_bps * dt; + } + total.max(0.0) as u64 +} + +pub fn evaluate( + db: &Db, + host: &HostInfo, + interval_secs: u64, + bwflow_tx_bytes: Option, +) -> ComplianceReport { + let ocpus = host.cpus as f64; + let mem = host.mem_total_bytes; + let disk = disk_total_bytes(); + let arch = std::env::consts::ARCH.to_string(); + let is_arm = arch == "aarch64" || arch == "arm"; + + let mut checks = Vec::new(); + let notes = vec![ + "Targets apply to this host’s measured shape and traffic.".into(), + "Outbound budget is tracked per calendar month and resets on the 1st.".into(), + ]; + + let ocpu_ok = ocpus <= TARGET_OCPU_LIMIT + 0.01; + checks.push(Check { + id: "ocpu".into(), + label: "CPU capacity".into(), + status: if ocpu_ok { "pass".into() } else { "fail".into() }, + detail: if ocpu_ok { + "CPU count is within the configured target.".into() + } else { + "CPU count is above the configured target (2 OCPU).".into() + }, + current: format!("{ocpus:.0} OCPU"), + limit: format!("≤ {TARGET_OCPU_LIMIT:.0} OCPU"), + }); + + let mem_ok = mem <= TARGET_MEM_BYTES + 512 * 1024 * 1024; + checks.push(Check { + id: "memory".into(), + label: "Memory capacity".into(), + status: if mem_ok { "pass".into() } else { "fail".into() }, + detail: if mem_ok { + "Installed memory is within the configured target.".into() + } else { + "Installed memory is above the configured target (12 GB).".into() + }, + current: format_bytes(mem), + limit: format!("≤ {}", format_bytes(TARGET_MEM_BYTES)), + }); + + let disk_ok = disk == 0 || disk <= TARGET_BLOCK_BYTES; + checks.push(Check { + id: "block".into(), + label: "Disk capacity".into(), + status: if disk == 0 { + "info".into() + } else if disk_ok { + "pass".into() + } else { + "fail".into() + }, + detail: if disk == 0 { + "Could not read root filesystem size.".into() + } else if !disk_ok { + "Root volume is above the configured disk target (200 GB).".into() + } else { + format!( + "Root volume is within the disk target ({} of {}).", + format_bytes(disk), + format_bytes(TARGET_BLOCK_BYTES) + ) + }, + current: if disk == 0 { + "unknown".into() + } else { + format_bytes(disk) + }, + limit: format!("≤ {}", format_bytes(TARGET_BLOCK_BYTES)), + }); + + checks.push(Check { + id: "arch".into(), + label: "Architecture".into(), + status: if is_arm { "pass".into() } else { "info".into() }, + detail: if is_arm { + "aarch64 host — matches the expected ARM shape for these targets.".into() + } else { + "Non-ARM host — capacity targets still apply; verify shape separately.".into() + }, + current: arch.clone(), + limit: "aarch64 preferred".into(), + }); + + let now = chrono::Utc::now().timestamp(); + let week_ago = now - 7 * 24 * 3600; + let week_samples = db.query_range(week_ago, now).unwrap_or_default(); + let mut cpu_vals: Vec = week_samples.iter().map(|s| s.cpu_pct).collect(); + let mem_vals: Vec = week_samples.iter().map(|s| s.mem_pct).collect(); + let bw = shape_bandwidth_bps(ocpus); + let net_utils: Vec = week_samples + .iter() + .map(|s| ((s.net_rx_bps + s.net_tx_bps) / bw * 100.0).min(100.0)) + .collect(); + + let cpu_p95 = percentile_95(&mut cpu_vals); + let mem_avg = avg_f32(&mem_vals); + let net_avg = avg_f64(&net_utils); + + let idle_cpu = cpu_p95.map(|v| v < IDLE_CPU_P95_PCT).unwrap_or(false); + let idle_mem = mem_avg.map(|v| v < IDLE_MEM_PCT).unwrap_or(false); + let idle_net = net_avg.map(|v| v < IDLE_NET_UTIL_PCT as f64).unwrap_or(false); + let low_activity = !week_samples.is_empty() && idle_cpu && idle_mem && idle_net; + + let coverage_hours = if week_samples.len() < 2 { + 0.0 + } else { + (week_samples.last().unwrap().ts - week_samples.first().unwrap().ts) as f64 / 3600.0 + }; + let enough_history = coverage_hours >= 24.0; + + let idle_status = if week_samples.is_empty() { + "info" + } else if low_activity && enough_history { + "warn" + } else if low_activity { + "info" + } else { + "pass" + }; + + checks.push(Check { + id: "idle_reclaim".into(), + label: "Activity floor (7-day)".into(), + status: idle_status.into(), + detail: { + let cpu_s = cpu_p95 + .map(|v| format!("{v:.1}%")) + .unwrap_or_else(|| "n/a".into()); + let mem_s = mem_avg + .map(|v| format!("{v:.1}%")) + .unwrap_or_else(|| "n/a".into()); + let net_s = net_avg + .map(|v| format!("{v:.2}%")) + .unwrap_or_else(|| "n/a".into()); + if week_samples.is_empty() { + "Not enough samples yet to judge activity.".into() + } else if low_activity { + format!( + "Sustained low activity on collected data (CPU p95 {cpu_s}, mem avg {mem_s}, net util avg {net_s}). History ≈ {coverage_hours:.0}h." + ) + } else { + format!( + "Activity above floor. CPU p95 {cpu_s}, mem avg {mem_s}, net util avg {net_s}. History ≈ {coverage_hours:.0}h." + ) + } + }, + current: format!( + "cpu_p95={} mem_avg={} net_avg={}", + cpu_p95.map(|v| format!("{v:.1}%")).unwrap_or_else(|| "n/a".into()), + mem_avg.map(|v| format!("{v:.1}%")).unwrap_or_else(|| "n/a".into()), + net_avg.map(|v| format!("{v:.2}%")).unwrap_or_else(|| "n/a".into()), + ), + limit: "keep some of: CPU p95≥20%, mem≥20%, or net≥20%".into(), + }); + + let month_samples = db + .query_range(month_start_ts(), now) + .unwrap_or_default(); + let sample_egress = estimate_month_egress(&month_samples, interval_secs as f64); + let (egress, egress_source) = match bwflow_tx_bytes { + Some(tx) => (tx, "bwflow"), + None => (sample_egress, "meterbox samples"), + }; + let egress_ok = egress <= TARGET_EGRESS_BYTES_MONTH; + let egress_pct = egress as f64 / TARGET_EGRESS_BYTES_MONTH as f64 * 100.0; + checks.push(Check { + id: "egress".into(), + label: "Outbound data (calendar month)".into(), + status: if bwflow_tx_bytes.is_none() && month_samples.len() < 10 { + "info".into() + } else if !egress_ok { + "fail".into() + } else if egress_pct > 80.0 { + "warn".into() + } else { + "pass".into() + }, + detail: if bwflow_tx_bytes.is_none() && month_samples.len() < 10 { + "Needs bwflow or more samples for this calendar month.".into() + } else { + format!( + "Calendar-month egress from {egress_source} ({egress_pct:.2}% of 10 TB target). Resets on the 1st." + ) + }, + current: format_bytes(egress), + limit: "≤ 10 TB / calendar month".into(), + }); + + let has_fail = checks.iter().any(|c| c.status == "fail"); + let has_warn = checks.iter().any(|c| c.status == "warn"); + let (overall, summary) = if has_fail { + ( + "over_limit".into(), + "One or more capacity targets are exceeded.".into(), + ) + } else if has_warn { + ( + "at_risk".into(), + "Within core targets, with warnings that need attention.".into(), + ) + } else { + ( + "compliant".into(), + "Host is within configured capacity and budget targets.".into(), + ) + }; + + ComplianceReport { + overall, + summary, + tier: "Host capacity targets".into(), + docs_url: String::new(), + notes, + checks, + host: HostSnapshot { + ocpus, + mem_total_bytes: mem, + disk_total_bytes: disk, + arch, + is_arm, + }, + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..87d0cff --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Debug, Clone, Parser)] +#[command(name = "meterbox", about = "Host resource usage dashboard")] +pub struct Config { + /// Bind address — default localhost only (safe behind Caddy/Rathole) + #[arg(long, env = "METERBOX_LISTEN", default_value = "127.0.0.1:8787")] + pub listen: String, + + /// SQLite database path + #[arg(long, env = "METERBOX_DB", default_value = "data/meterbox.db")] + pub db_path: PathBuf, + + /// Directory with built React assets + #[arg(long, env = "METERBOX_STATIC", default_value = "frontend/dist")] + pub static_dir: PathBuf, + + /// Sample interval in seconds + #[arg(long, env = "METERBOX_INTERVAL_SECS", default_value_t = 30)] + pub interval_secs: u64, + + /// Retain samples for this many days (max ~3 months) + #[arg(long, env = "METERBOX_RETENTION_DAYS", default_value_t = 90)] + pub retention_days: i64, + + /// Hard cap for SQLite on-disk size (main + WAL + SHM), bytes. Default 10 GiB. + #[arg(long, env = "METERBOX_MAX_DB_BYTES", default_value_t = 10 * 1024 * 1024 * 1024)] + pub max_db_bytes: u64, + + /// How often the cleanup job runs (seconds). Default 1 hour. + #[arg(long, env = "METERBOX_CLEANUP_INTERVAL_SECS", default_value_t = 3600)] + pub cleanup_interval_secs: u64, +} + +impl Config { + pub fn ensure_dirs(&self) -> anyhow::Result<()> { + if let Some(parent) = self.db_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + Ok(()) + } +} diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..6100c8d --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,356 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::metrics::Sample; + +pub struct Db { + path: PathBuf, + pub(crate) conn: Mutex, +} + +#[derive(Debug, Clone)] +pub struct CleanupReport { + pub deleted_by_age: usize, + pub deleted_by_size: usize, + pub vacuumed: bool, + pub bytes_before: u64, + pub bytes_after: u64, + pub rows_after: i64, +} + +impl Db { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?; + conn.execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA temp_store = MEMORY; + PRAGMA foreign_keys = ON; + ", + )?; + Ok(Self { + path: path.to_path_buf(), + conn: Mutex::new(conn), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn migrate(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS samples ( + ts INTEGER PRIMARY KEY, + cpu_pct REAL NOT NULL, + mem_used_bytes INTEGER NOT NULL, + mem_total_bytes INTEGER NOT NULL, + mem_pct REAL NOT NULL, + disk_used_bytes INTEGER NOT NULL DEFAULT 0, + disk_total_bytes INTEGER NOT NULL DEFAULT 0, + disk_pct REAL NOT NULL DEFAULT 0, + net_rx_bps REAL NOT NULL, + net_tx_bps REAL NOT NULL, + load1 REAL NOT NULL, + load5 REAL NOT NULL, + load15 REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + + CREATE TABLE IF NOT EXISTS health_sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + interval_secs INTEGER NOT NULL DEFAULT 60, + timeout_ms INTEGER NOT NULL DEFAULT 10000, + expected_status INTEGER NOT NULL DEFAULT 200, + expect_body_contains TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_check_at INTEGER, + last_ok INTEGER, + last_status INTEGER, + last_latency_ms INTEGER, + last_error TEXT, + last_via_cloudflare INTEGER, + last_cf_fallback INTEGER, + last_cf_challenge INTEGER + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_health_sites_url ON health_sites(url); + + CREATE TABLE IF NOT EXISTS health_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL REFERENCES health_sites(id) ON DELETE CASCADE, + checked_at INTEGER NOT NULL, + ok INTEGER NOT NULL, + status INTEGER NOT NULL, + latency_ms INTEGER NOT NULL, + via_cloudflare INTEGER NOT NULL, + cf_fallback INTEGER NOT NULL, + cf_challenge INTEGER NOT NULL, + error TEXT, + reasons_json TEXT NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_health_results_site_ts ON health_results(site_id, checked_at DESC); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + ", + )?; + // Additive migrations for samples disk columns (existing DBs). + let _ = conn.execute( + "ALTER TABLE samples ADD COLUMN disk_used_bytes INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE samples ADD COLUMN disk_total_bytes INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE samples ADD COLUMN disk_pct REAL NOT NULL DEFAULT 0", + [], + ); + Ok(()) + } + + pub fn insert_sample(&self, s: &Sample) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO samples + (ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct, + disk_used_bytes, disk_total_bytes, disk_pct, + net_rx_bps, net_tx_bps, load1, load5, load15) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + s.ts, + s.cpu_pct, + s.mem_used_bytes as i64, + s.mem_total_bytes as i64, + s.mem_pct, + s.disk_used_bytes as i64, + s.disk_total_bytes as i64, + s.disk_pct, + s.net_rx_bps, + s.net_tx_bps, + s.load1, + s.load5, + s.load15, + ], + )?; + Ok(()) + } + + pub fn prune_older_than(&self, older_than_ts: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute("DELETE FROM samples WHERE ts < ?1", params![older_than_ts])?; + Ok(n) + } + + /// Delete the oldest `limit` rows. Returns how many were removed. + pub fn prune_oldest(&self, limit: i64) -> Result { + if limit <= 0 { + return Ok(0); + } + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "DELETE FROM samples WHERE ts IN ( + SELECT ts FROM samples ORDER BY ts ASC LIMIT ?1 + )", + params![limit], + )?; + Ok(n) + } + + pub fn checkpoint_truncate(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; + Ok(()) + } + + pub fn vacuum(&self) -> Result<()> { + // VACUUM cannot run inside a transaction; unlock and run dedicated connection. + { + let conn = self.conn.lock().unwrap(); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; + } + let conn = Connection::open(&self.path)?; + conn.execute_batch("VACUUM;")?; + // Re-apply WAL on the long-lived connection + let conn = self.conn.lock().unwrap(); + conn.execute_batch("PRAGMA journal_mode = WAL;")?; + Ok(()) + } + + /// On-disk size of main DB + WAL + SHM. + pub fn on_disk_bytes(&self) -> Result { + Ok(file_size(&self.path)? + + file_size(Path::new(&format!("{}-wal", self.path.display())))? + + file_size(Path::new(&format!("{}-shm", self.path.display())))?) + } + + pub fn latest(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct, + disk_used_bytes, disk_total_bytes, disk_pct, + net_rx_bps, net_tx_bps, load1, load5, load15 + FROM samples ORDER BY ts DESC LIMIT 1", + [], + row_to_sample, + ) + .optional() + .map_err(Into::into) + } + + pub fn query_range(&self, from_ts: i64, to_ts: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct, + disk_used_bytes, disk_total_bytes, disk_pct, + net_rx_bps, net_tx_bps, load1, load5, load15 + FROM samples WHERE ts >= ?1 AND ts <= ?2 ORDER BY ts ASC", + )?; + let rows = stmt.query_map(params![from_ts, to_ts], row_to_sample)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn.query_row("SELECT COUNT(*) FROM samples", [], |r| r.get(0))?; + Ok(n) + } + + pub fn oldest_ts(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row("SELECT MIN(ts) FROM samples", [], |r| r.get(0)) + .optional() + .map_err(Into::into) + } + + /// Age retention + hard size cap. Shrinks to `target_bytes` when over `max_bytes`. + pub fn run_cleanup( + &self, + retention_days: i64, + max_bytes: u64, + target_bytes: u64, + ) -> Result { + let bytes_before = self.on_disk_bytes()?; + let mut deleted_by_age = 0usize; + let mut deleted_by_size = 0usize; + let mut vacuumed = false; + + let cutoff = chrono::Utc::now().timestamp() - retention_days.max(1) * 24 * 60 * 60; + deleted_by_age = self.prune_older_than(cutoff)?; + + // Keep deleting oldest batches until under target when over the hard cap. + let mut guard = 0; + while self.on_disk_bytes()? > max_bytes.max(target_bytes) && guard < 64 { + guard += 1; + let rows = self.count()?.max(1); + // Remove ~5% of rows each pass (min 1k, max 100k) so we converge quickly. + let batch = ((rows as f64) * 0.05).ceil() as i64; + let batch = batch.clamp(1_000, 100_000); + let n = self.prune_oldest(batch)?; + if n == 0 { + break; + } + deleted_by_size += n; + let _ = self.checkpoint_truncate(); + } + + let needs_reclaim = deleted_by_age > 0 + || deleted_by_size > 0 + || self.on_disk_bytes()? > target_bytes; + if needs_reclaim && self.on_disk_bytes()? > target_bytes { + self.vacuum()?; + vacuumed = true; + } else if deleted_by_age > 0 || deleted_by_size > 0 { + let _ = self.checkpoint_truncate(); + } + + // If still over hard cap after vacuum, keep trimming (pathological case). + guard = 0; + while self.on_disk_bytes()? > max_bytes && guard < 32 { + guard += 1; + let rows = self.count()?.max(1); + let batch = ((rows as f64) * 0.10).ceil() as i64; + let batch = batch.clamp(5_000, 200_000); + let n = self.prune_oldest(batch)?; + if n == 0 { + break; + } + deleted_by_size += n; + self.vacuum()?; + vacuumed = true; + } + + Ok(CleanupReport { + deleted_by_age, + deleted_by_size, + vacuumed, + bytes_before, + bytes_after: self.on_disk_bytes()?, + rows_after: self.count()?, + }) + } +} + +fn file_size(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(m) => Ok(m.len()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(e) => Err(e.into()), + } +} + +fn row_to_sample(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Sample { + ts: row.get(0)?, + cpu_pct: row.get(1)?, + mem_used_bytes: row.get::<_, i64>(2)? as u64, + mem_total_bytes: row.get::<_, i64>(3)? as u64, + mem_pct: row.get(4)?, + disk_used_bytes: row.get::<_, i64>(5)? as u64, + disk_total_bytes: row.get::<_, i64>(6)? as u64, + disk_pct: row.get(7)?, + net_rx_bps: row.get(8)?, + net_tx_bps: row.get(9)?, + load1: row.get(10)?, + load5: row.get(11)?, + load15: row.get(12)?, + }) +} diff --git a/backend/src/health.rs b/backend/src/health.rs new file mode 100644 index 0000000..946d0e5 --- /dev/null +++ b/backend/src/health.rs @@ -0,0 +1,596 @@ +//! Configurable site health checks stored in SQLite. + +use anyhow::{bail, Context, Result}; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use crate::db::Db; +use crate::probe::{probe_url, ProbeResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthSite { + pub id: i64, + pub name: String, + pub url: String, + pub enabled: bool, + pub interval_secs: i64, + pub timeout_ms: i64, + pub expected_status: i64, + pub expect_body_contains: Option, + pub created_at: i64, + pub updated_at: i64, + pub last_check_at: Option, + pub last_ok: Option, + pub last_status: Option, + pub last_latency_ms: Option, + pub last_error: Option, + pub last_via_cloudflare: Option, + pub last_cf_fallback: Option, + pub last_cf_challenge: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpsertSite { + pub name: String, + pub url: String, + pub enabled: Option, + pub interval_secs: Option, + pub timeout_ms: Option, + pub expected_status: Option, + pub expect_body_contains: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HealthCheckResult { + pub id: i64, + pub site_id: i64, + pub checked_at: i64, + pub ok: bool, + pub status: i64, + pub latency_ms: i64, + pub via_cloudflare: bool, + pub cf_fallback: bool, + pub cf_challenge: bool, + pub error: Option, + pub reasons: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HealthIncident { + pub started_at: i64, + pub ended_at: Option, + pub duration_secs: Option, + pub ongoing: bool, + pub error: String, + pub status: Option, + pub checks: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HistorySummary { + pub checks: i64, + pub ok: i64, + pub down: i64, + pub uptime_pct: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SiteHistory { + pub site: HealthSite, + pub range: String, + pub from_ts: i64, + pub to_ts: i64, + pub bucket_secs: i64, + pub summary: HistorySummary, + pub incidents: Vec, + pub series: Vec, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HealthSeriesPoint { + pub ts: i64, + /// Fraction healthy in bucket, 0–100. + pub uptime_pct: f64, + pub ok: i64, + pub down: i64, + pub avg_latency_ms: f64, + /// 1 if any check failed in the bucket, else 0 (for status chart). + pub had_down: i64, +} + +fn parse_health_range(range: &str) -> (i64, i64) { + // (window_secs, bucket_secs) + match range { + "1h" => (3600, 60), + "12h" => (12 * 3600, 5 * 60), + "24h" => (24 * 3600, 10 * 60), + "7d" => (7 * 86400, 3600), + "30d" => (30 * 86400, 4 * 3600), + _ => (24 * 3600, 10 * 60), + } +} + +/// Collapse consecutive failures into downtime incidents (newest first). +fn derive_incidents(results_newest_first: &[HealthCheckResult]) -> Vec { + let mut chronological: Vec<&HealthCheckResult> = results_newest_first.iter().collect(); + chronological.reverse(); + + let mut incidents = Vec::new(); + let mut current: Option<(i64, String, Option, i64)> = None; // start, error, status, checks + + for r in &chronological { + if !r.ok { + match &mut current { + Some((_start, error, status, checks)) => { + *checks += 1; + if error.is_empty() { + if let Some(e) = &r.error { + *error = e.clone(); + } + } + if status.is_none() && r.status > 0 { + *status = Some(r.status); + } + } + None => { + current = Some(( + r.checked_at, + r.error.clone().unwrap_or_else(|| "health check failed".into()), + if r.status > 0 { Some(r.status) } else { None }, + 1, + )); + } + } + } else if let Some((started_at, error, status, checks)) = current.take() { + incidents.push(HealthIncident { + started_at, + ended_at: Some(r.checked_at), + duration_secs: Some((r.checked_at - started_at).max(0)), + ongoing: false, + error, + status, + checks, + }); + } + } + + if let Some((started_at, error, status, checks)) = current { + let ended = chronological.last().map(|r| r.checked_at); + incidents.push(HealthIncident { + started_at, + ended_at: None, + duration_secs: ended.map(|e| (e - started_at).max(0)), + ongoing: true, + error, + status, + checks, + }); + } + + incidents.reverse(); // newest first + incidents +} + +impl Db { + pub fn list_health_sites(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, url, enabled, interval_secs, timeout_ms, expected_status, + expect_body_contains, created_at, updated_at, last_check_at, last_ok, + last_status, last_latency_ms, last_error, last_via_cloudflare, + last_cf_fallback, last_cf_challenge + FROM health_sites ORDER BY name COLLATE NOCASE ASC", + )?; + let rows = stmt.query_map([], row_to_site)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn get_health_site(&self, id: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, name, url, enabled, interval_secs, timeout_ms, expected_status, + expect_body_contains, created_at, updated_at, last_check_at, last_ok, + last_status, last_latency_ms, last_error, last_via_cloudflare, + last_cf_fallback, last_cf_challenge + FROM health_sites WHERE id = ?1", + params![id], + row_to_site, + ) + .optional() + .map_err(Into::into) + } + + pub fn insert_health_site(&self, input: &UpsertSite) -> Result { + let name = input.name.trim(); + let url = input.url.trim(); + if name.is_empty() { + bail!("name is required"); + } + if url.is_empty() { + bail!("url is required"); + } + let now = chrono::Utc::now().timestamp(); + let enabled = input.enabled.unwrap_or(true); + let interval_secs = input.interval_secs.unwrap_or(60).clamp(15, 3600); + let timeout_ms = input.timeout_ms.unwrap_or(10_000).clamp(1000, 60_000); + let expected_status = input.expected_status.unwrap_or(200).clamp(100, 599); + let expect_body = input + .expect_body_contains + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO health_sites + (name, url, enabled, interval_secs, timeout_ms, expected_status, expect_body_contains, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + name, + url, + enabled as i64, + interval_secs, + timeout_ms, + expected_status, + expect_body, + now, + now + ], + ) + .context("insert health site")?; + let id = conn.last_insert_rowid(); + drop(conn); + self.get_health_site(id)? + .ok_or_else(|| anyhow::anyhow!("site missing after insert")) + } + + pub fn update_health_site(&self, id: i64, input: &UpsertSite) -> Result { + let existing = self + .get_health_site(id)? + .ok_or_else(|| anyhow::anyhow!("site not found"))?; + let name = input.name.trim(); + let url = input.url.trim(); + if name.is_empty() || url.is_empty() { + bail!("name and url are required"); + } + let now = chrono::Utc::now().timestamp(); + let enabled = input.enabled.unwrap_or(existing.enabled); + let interval_secs = input + .interval_secs + .unwrap_or(existing.interval_secs) + .clamp(15, 3600); + let timeout_ms = input + .timeout_ms + .unwrap_or(existing.timeout_ms) + .clamp(1000, 60_000); + let expected_status = input + .expected_status + .unwrap_or(existing.expected_status) + .clamp(100, 599); + let expect_body = input + .expect_body_contains + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE health_sites SET + name = ?1, url = ?2, enabled = ?3, interval_secs = ?4, timeout_ms = ?5, + expected_status = ?6, expect_body_contains = ?7, updated_at = ?8 + WHERE id = ?9", + params![ + name, + url, + enabled as i64, + interval_secs, + timeout_ms, + expected_status, + expect_body, + now, + id + ], + )?; + drop(conn); + self.get_health_site(id)? + .ok_or_else(|| anyhow::anyhow!("site missing after update")) + } + + pub fn delete_health_site(&self, id: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute("DELETE FROM health_sites WHERE id = ?1", params![id])?; + Ok(n > 0) + } + + pub fn record_health_result(&self, site_id: i64, probe: &ProbeResult) -> Result<()> { + let now = chrono::Utc::now().timestamp(); + let error_text = if probe.ok { + None + } else { + Some( + probe + .error + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| probe.reasons.join("; ")), + ) + .filter(|s| !s.is_empty()) + .or_else(|| Some("health check failed".into())) + }; + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO health_results + (site_id, checked_at, ok, status, latency_ms, via_cloudflare, cf_fallback, cf_challenge, error, reasons_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + site_id, + now, + probe.ok as i64, + probe.status as i64, + probe.latency_ms as i64, + probe.via_cloudflare as i64, + probe.cloudflare_fallback as i64, + probe.cloudflare_challenge as i64, + error_text, + serde_json::to_string(&probe.reasons).unwrap_or_else(|_| "[]".into()), + ], + )?; + conn.execute( + "UPDATE health_sites SET + last_check_at = ?1, + last_ok = ?2, + last_status = ?3, + last_latency_ms = ?4, + last_error = ?5, + last_via_cloudflare = ?6, + last_cf_fallback = ?7, + last_cf_challenge = ?8, + updated_at = ?1 + WHERE id = ?9", + params![ + now, + probe.ok as i64, + probe.status as i64, + probe.latency_ms as i64, + error_text, + probe.via_cloudflare as i64, + probe.cloudflare_fallback as i64, + probe.cloudflare_challenge as i64, + site_id + ], + )?; + // Age pruning of health logs is controlled by settings (3-month checkbox). + Ok(()) + } + + pub fn list_health_results_since( + &self, + site_id: i64, + since: i64, + limit: i64, + ) -> Result> { + let limit = limit.clamp(1, 50_000); + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, site_id, checked_at, ok, status, latency_ms, via_cloudflare, + cf_fallback, cf_challenge, error, reasons_json + FROM health_results + WHERE site_id = ?1 AND checked_at >= ?2 + ORDER BY checked_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(params![site_id, since, limit], row_to_result)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn site_history(&self, site_id: i64, range: &str) -> Result> { + let site = match self.get_health_site(site_id)? { + Some(s) => s, + None => return Ok(None), + }; + let (window, bucket) = parse_health_range(range); + let to_ts = chrono::Utc::now().timestamp(); + let from_ts = to_ts - window; + let results = self.list_health_results_since(site_id, from_ts, 50_000)?; + let total = results.len() as i64; + let ok_count = results.iter().filter(|r| r.ok).count() as i64; + let down_count = total - ok_count; + let uptime_pct = if total == 0 { + None + } else { + Some((ok_count as f64) * 100.0 / (total as f64)) + }; + let incidents = derive_incidents(&results); + let series = bucket_results(&results, from_ts, to_ts, bucket); + Ok(Some(SiteHistory { + site, + range: normalize_range(range), + from_ts, + to_ts, + bucket_secs: bucket, + results, + incidents, + series, + summary: HistorySummary { + checks: total, + ok: ok_count, + down: down_count, + uptime_pct, + }, + })) + } + + pub fn sites_due_for_check(&self, now: i64) -> Result> { + let sites = self.list_health_sites()?; + Ok(sites + .into_iter() + .filter(|s| s.enabled) + .filter(|s| match s.last_check_at { + None => true, + Some(ts) => now - ts >= s.interval_secs, + }) + .collect()) + } +} + +fn row_to_result(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let reasons_json: String = row.get(10)?; + let reasons: Vec = serde_json::from_str(&reasons_json).unwrap_or_default(); + Ok(HealthCheckResult { + id: row.get(0)?, + site_id: row.get(1)?, + checked_at: row.get(2)?, + ok: row.get::<_, i64>(3)? != 0, + status: row.get(4)?, + latency_ms: row.get(5)?, + via_cloudflare: row.get::<_, i64>(6)? != 0, + cf_fallback: row.get::<_, i64>(7)? != 0, + cf_challenge: row.get::<_, i64>(8)? != 0, + error: row.get(9)?, + reasons, + }) +} + +fn normalize_range(range: &str) -> String { + match range { + "1h" | "12h" | "24h" | "7d" | "30d" => range.to_string(), + _ => "24h".into(), + } +} + +fn bucket_results( + results_newest_first: &[HealthCheckResult], + from_ts: i64, + to_ts: i64, + bucket: i64, +) -> Vec { + if bucket <= 0 || to_ts <= from_ts { + return Vec::new(); + } + let n = ((to_ts - from_ts) / bucket) as usize + 1; + let mut ok = vec![0i64; n]; + let mut down = vec![0i64; n]; + let mut lat_sum = vec![0f64; n]; + let mut lat_n = vec![0i64; n]; + + for r in results_newest_first { + if r.checked_at < from_ts || r.checked_at > to_ts { + continue; + } + let idx = ((r.checked_at - from_ts) / bucket) as usize; + let idx = idx.min(n - 1); + if r.ok { + ok[idx] += 1; + } else { + down[idx] += 1; + } + lat_sum[idx] += r.latency_ms as f64; + lat_n[idx] += 1; + } + + (0..n) + .map(|i| { + let total = ok[i] + down[i]; + let uptime_pct = if total == 0 { + // empty bucket: treat as unknown → show as 100 only if we leave gaps; + // use NaN-friendly 100 for continuity when no data, but mark had_down=0 + f64::NAN + } else { + (ok[i] as f64) * 100.0 / (total as f64) + }; + HealthSeriesPoint { + ts: from_ts + (i as i64) * bucket, + uptime_pct: if uptime_pct.is_nan() { 100.0 } else { uptime_pct }, + ok: ok[i], + down: down[i], + avg_latency_ms: if lat_n[i] == 0 { + 0.0 + } else { + lat_sum[i] / lat_n[i] as f64 + }, + had_down: if down[i] > 0 { 1 } else { 0 }, + } + }) + .filter(|p| p.ok + p.down > 0) // only buckets with data + .collect() +} + +fn row_to_site(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(HealthSite { + id: row.get(0)?, + name: row.get(1)?, + url: row.get(2)?, + enabled: row.get::<_, i64>(3)? != 0, + interval_secs: row.get(4)?, + timeout_ms: row.get(5)?, + expected_status: row.get(6)?, + expect_body_contains: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + last_check_at: row.get(10)?, + last_ok: row + .get::<_, Option>(11)? + .map(|v| v != 0), + last_status: row.get(12)?, + last_latency_ms: row.get(13)?, + last_error: row.get(14)?, + last_via_cloudflare: row + .get::<_, Option>(15)? + .map(|v| v != 0), + last_cf_fallback: row + .get::<_, Option>(16)? + .map(|v| v != 0), + last_cf_challenge: row + .get::<_, Option>(17)? + .map(|v| v != 0), + }) +} + +pub fn spawn(state: crate::AppState) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + let _ = state.db.prune_expired_sessions(); + let now = chrono::Utc::now().timestamp(); + let due = match state.db.sites_due_for_check(now) { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "list due health sites failed"); + continue; + } + }; + for site in due { + let expect = site.expect_body_contains.clone(); + let probe = probe_url( + &site.url, + site.expected_status as u16, + expect.as_deref(), + site.timeout_ms as u64, + ) + .await; + if let Err(e) = state.db.record_health_result(site.id, &probe) { + tracing::warn!(site_id = site.id, error = %e, "record health result failed"); + } else if !probe.ok { + tracing::info!( + site = %site.name, + status = probe.status, + cf_fallback = probe.cloudflare_fallback, + cf_challenge = probe.cloudflare_challenge, + "health check failed" + ); + } + } + } + }); +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..34f4606 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,86 @@ +mod api; +mod auth; +mod bwflow; +mod cleanup; +mod collector; +mod compliance; +mod config; +mod db; +mod health; +mod metrics; +mod probe; +mod settings; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Router; +use clap::Parser; +use tower_http::compression::CompressionLayer; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::EnvFilter; + +use crate::config::Config; +use crate::db::Db; + +#[derive(Clone)] +pub struct AppState { + pub db: Arc, + pub config: Arc, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "meterbox=info".into())) + .init(); + + let config = Arc::new(Config::parse()); + config.ensure_dirs()?; + + let db = Arc::new(Db::open(&config.db_path)?); + db.migrate()?; + + let state = AppState { + db: db.clone(), + config: config.clone(), + }; + + collector::spawn(state.clone()); + cleanup::spawn(state.clone()); + health::spawn(state.clone()); + + let static_dir = config.static_dir.clone(); + let index = static_dir.join("index.html"); + + let api = api::router(state); + + let app = Router::new() + .nest("/api", api) + .fallback_service( + ServeDir::new(&static_dir) + .not_found_service(ServeFile::new(index)), + ) + .layer(CompressionLayer::new()) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .layer(TraceLayer::new_for_http()); + + let addr: SocketAddr = config.listen.parse()?; + tracing::info!(%addr, db = %config.db_path.display(), "meterbox listening"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +#[allow(dead_code)] +fn default_static() -> PathBuf { + PathBuf::from("frontend/dist") +} diff --git a/backend/src/metrics.rs b/backend/src/metrics.rs new file mode 100644 index 0000000..5da388d --- /dev/null +++ b/backend/src/metrics.rs @@ -0,0 +1,203 @@ +use serde::Serialize; +use sysinfo::{Networks, System}; + +#[derive(Debug, Clone, Serialize)] +pub struct Sample { + pub ts: i64, + pub cpu_pct: f32, + pub mem_used_bytes: u64, + pub mem_total_bytes: u64, + pub mem_pct: f32, + pub disk_used_bytes: u64, + pub disk_total_bytes: u64, + pub disk_pct: f32, + pub net_rx_bps: f64, + pub net_tx_bps: f64, + pub load1: f64, + pub load5: f64, + pub load15: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HostInfo { + pub hostname: String, + pub os: String, + pub kernel: String, + pub cpus: usize, + pub mem_total_bytes: u64, + pub disk_used_bytes: u64, + pub disk_total_bytes: u64, + pub disk_pct: f32, + pub uptime_secs: u64, +} + +#[derive(Debug, Clone, Copy)] +pub struct DiskUsage { + pub used_bytes: u64, + pub total_bytes: u64, + pub pct: f32, +} + +/// Boot / root filesystem usage via `statvfs("/")`. +pub fn boot_disk_usage() -> DiskUsage { + #[cfg(target_os = "linux")] + { + use std::ffi::CString; + let Ok(path) = CString::new("/") else { + return DiskUsage { + used_bytes: 0, + total_bytes: 0, + pct: 0.0, + }; + }; + let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(path.as_ptr(), &mut buf) }; + if rc != 0 { + return DiskUsage { + used_bytes: 0, + total_bytes: 0, + pct: 0.0, + }; + } + let frsize = buf.f_frsize as u64; + let total = buf.f_blocks as u64 * frsize; + let available = buf.f_bavail as u64 * frsize; + let used = total.saturating_sub(available); + let pct = if total > 0 { + (used as f64 / total as f64 * 100.0) as f32 + } else { + 0.0 + }; + return DiskUsage { + used_bytes: used, + total_bytes: total, + pct, + }; + } + #[cfg(not(target_os = "linux"))] + { + DiskUsage { + used_bytes: 0, + total_bytes: 0, + pct: 0.0, + } + } +} + +pub struct Sampler { + sys: System, + networks: Networks, + last_rx: u64, + last_tx: u64, + last_ts: Option, + primed: bool, +} + +impl Sampler { + pub fn new() -> Self { + let mut sys = System::new(); + sys.refresh_memory(); + sys.refresh_cpu_all(); + let networks = Networks::new_with_refreshed_list(); + Self { + sys, + networks, + last_rx: 0, + last_tx: 0, + last_ts: None, + primed: false, + } + } + + pub fn host_info(&mut self) -> HostInfo { + self.sys.refresh_memory(); + let disk = boot_disk_usage(); + HostInfo { + hostname: hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown".into()), + os: System::long_os_version().unwrap_or_else(|| "unknown".into()), + kernel: System::kernel_version().unwrap_or_else(|| "unknown".into()), + cpus: self + .sys + .physical_core_count() + .unwrap_or_else(|| self.sys.cpus().len()) + .max(1), + mem_total_bytes: self.sys.total_memory(), + disk_used_bytes: disk.used_bytes, + disk_total_bytes: disk.total_bytes, + disk_pct: disk.pct, + uptime_secs: System::uptime(), + } + } + + pub fn sample(&mut self) -> Sample { + self.sys.refresh_cpu_all(); + self.sys.refresh_memory(); + self.networks.refresh(true); + + let now = chrono::Utc::now().timestamp(); + let cpu_pct = self.sys.global_cpu_usage(); + let mem_total = self.sys.total_memory(); + let mem_used = self.sys.used_memory(); + let mem_pct = if mem_total > 0 { + (mem_used as f64 / mem_total as f64 * 100.0) as f32 + } else { + 0.0 + }; + let disk = boot_disk_usage(); + + let (rx, tx) = aggregate_networks(&self.networks); + let (rx_bps, tx_bps) = if let Some(prev) = self.last_ts { + let dt = (now - prev).max(1) as f64; + if self.primed && rx >= self.last_rx && tx >= self.last_tx { + ( + (rx - self.last_rx) as f64 / dt, + (tx - self.last_tx) as f64 / dt, + ) + } else { + (0.0, 0.0) + } + } else { + (0.0, 0.0) + }; + + self.last_rx = rx; + self.last_tx = tx; + self.last_ts = Some(now); + self.primed = true; + + let load = System::load_average(); + + Sample { + ts: now, + cpu_pct, + mem_used_bytes: mem_used, + mem_total_bytes: mem_total, + mem_pct, + disk_used_bytes: disk.used_bytes, + disk_total_bytes: disk.total_bytes, + disk_pct: disk.pct, + net_rx_bps: rx_bps, + net_tx_bps: tx_bps, + load1: load.one, + load5: load.five, + load15: load.fifteen, + } + } +} + +fn aggregate_networks(networks: &Networks) -> (u64, u64) { + let mut rx = 0u64; + let mut tx = 0u64; + for (name, data) in networks.iter() { + let n = name.to_lowercase(); + if n == "lo" || n.starts_with("docker") || n.starts_with("br-") || n.starts_with("veth") { + continue; + } + rx = rx.saturating_add(data.total_received()); + tx = tx.saturating_add(data.total_transmitted()); + } + (rx, tx) +} diff --git a/backend/src/probe.rs b/backend/src/probe.rs new file mode 100644 index 0000000..54414d0 --- /dev/null +++ b/backend/src/probe.rs @@ -0,0 +1,230 @@ +//! HTTP health probes with Cloudflare fallback / challenge detection. + +use std::time::{Duration, Instant}; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct ProbeResult { + pub ok: bool, + pub url: String, + pub final_url: String, + pub status: u16, + pub latency_ms: u64, + pub server: Option, + pub via_cloudflare: bool, + pub cloudflare_fallback: bool, + pub cloudflare_challenge: bool, + pub body_snippet: String, + pub error: Option, + pub reasons: Vec, +} + +const CF_ORIGIN_ERRORS: &[u16] = &[520, 521, 522, 523, 524, 525, 526, 527, 530]; + +const CF_BODY_MARKERS: &[&str] = &[ + "cloudflare ray id", + "cf-error-details", + "attention required! | cloudflare", + "just a moment...", + "enable javascript and cookies to continue", + "cdn-cgi/challenge", + "cdn-cgi/l/chk_jschl", + "always online", + "error 1000", + "error 1001", + "error 1014", + "error 1015", + "error 1016", + "origin is unreachable", + "web server is down", + "connection timed out", + "a timeout occurred", + "ssl handshake failed", + "invalid ssl certificate", + "please enable cookies", + "checking if the site connection is secure", +]; + +pub async fn probe_url( + url: &str, + expected_status: u16, + expect_body_contains: Option<&str>, + timeout_ms: u64, +) -> ProbeResult { + let mut reasons = Vec::new(); + let parsed = match reqwest::Url::parse(url) { + Ok(u) => u, + Err(e) => { + return ProbeResult { + ok: false, + url: url.to_string(), + final_url: url.to_string(), + status: 0, + latency_ms: 0, + server: None, + via_cloudflare: false, + cloudflare_fallback: false, + cloudflare_challenge: false, + body_snippet: String::new(), + error: Some(format!("invalid url: {e}")), + reasons: vec!["URL failed to parse".into()], + }; + } + }; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return ProbeResult { + ok: false, + url: url.to_string(), + final_url: url.to_string(), + status: 0, + latency_ms: 0, + server: None, + via_cloudflare: false, + cloudflare_fallback: false, + cloudflare_challenge: false, + body_snippet: String::new(), + error: Some("only http/https URLs are supported".into()), + reasons: vec!["Unsupported URL scheme".into()], + }; + } + + let client = match reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms.max(1000))) + .redirect(reqwest::redirect::Policy::limited(5)) + .user_agent("MeterboxHealthCheck/0.1 (+local)") + .build() + { + Ok(c) => c, + Err(e) => { + return ProbeResult { + ok: false, + url: url.to_string(), + final_url: url.to_string(), + status: 0, + latency_ms: 0, + server: None, + via_cloudflare: false, + cloudflare_fallback: false, + cloudflare_challenge: false, + body_snippet: String::new(), + error: Some(format!("client build failed: {e}")), + reasons: vec!["HTTP client error".into()], + }; + } + }; + + let started = Instant::now(); + let response = match client.get(parsed.clone()).send().await { + Ok(r) => r, + Err(e) => { + return ProbeResult { + ok: false, + url: url.to_string(), + final_url: url.to_string(), + status: 0, + latency_ms: started.elapsed().as_millis() as u64, + server: None, + via_cloudflare: false, + cloudflare_fallback: false, + cloudflare_challenge: false, + body_snippet: String::new(), + error: Some(format!("request failed: {e}")), + reasons: vec!["Request failed (network/TLS/DNS)".into()], + }; + } + }; + + let latency_ms = started.elapsed().as_millis() as u64; + let status = response.status().as_u16(); + let final_url = response.url().to_string(); + let server = response + .headers() + .get("server") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let cf_ray = response.headers().get("cf-ray").is_some(); + let cf_mitigated = response.headers().get("cf-mitigated").is_some(); + let via_cloudflare = cf_ray + || server + .as_deref() + .map(|s| s.to_ascii_lowercase().contains("cloudflare")) + .unwrap_or(false); + + let body = response.text().await.unwrap_or_default(); + let body_lower = body.to_ascii_lowercase(); + let snippet: String = body.chars().take(280).collect(); + + let mut cloudflare_fallback = false; + let mut cloudflare_challenge = false; + + if CF_ORIGIN_ERRORS.contains(&status) { + cloudflare_fallback = true; + reasons.push(format!( + "Cloudflare origin error status {status} (site not reachable behind CF)" + )); + } + + if cf_mitigated { + cloudflare_challenge = true; + reasons.push("cf-mitigated header present (Cloudflare challenge/bot fight)".into()); + } + + for marker in CF_BODY_MARKERS { + if body_lower.contains(marker) { + if marker.contains("just a moment") + || marker.contains("challenge") + || marker.contains("checking if the site") + || marker.contains("enable javascript") + { + cloudflare_challenge = true; + reasons.push(format!("Response body looks like a Cloudflare challenge ({marker})")); + } else { + cloudflare_fallback = true; + reasons.push(format!("Response body matches Cloudflare fallback/error page ({marker})")); + } + break; + } + } + + // Soft 200 that is clearly CF interstitial + if status == 200 && via_cloudflare && (cloudflare_challenge || cloudflare_fallback) { + reasons.push("Got HTTP 200 but content is Cloudflare interstitial/fallback, not origin".into()); + } + + let mut ok = true; + if status != expected_status { + ok = false; + reasons.push(format!( + "Expected HTTP {expected_status}, got {status}" + )); + } + if cloudflare_fallback || cloudflare_challenge { + ok = false; + } + if let Some(needle) = expect_body_contains { + let needle = needle.trim(); + if !needle.is_empty() && !body.contains(needle) { + ok = false; + reasons.push(format!("Body does not contain expected text: {needle:?}")); + } + } + if ok { + reasons.push("Probe passed origin health checks".into()); + } + + ProbeResult { + ok, + url: url.to_string(), + final_url, + status, + latency_ms, + server, + via_cloudflare, + cloudflare_fallback, + cloudflare_challenge, + body_snippet: snippet, + error: None, + reasons, + } +} diff --git a/backend/src/settings.rs b/backend/src/settings.rs new file mode 100644 index 0000000..6f07bc7 --- /dev/null +++ b/backend/src/settings.rs @@ -0,0 +1,153 @@ +//! Persistent app settings (SQLite key/value). + +use anyhow::{Context, Result}; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +use crate::db::Db; + +pub const HEALTH_LOG_RETENTION_DAYS: i64 = 90; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + /// When true, prune health check logs older than 3 months. + pub cleanup_logs_older_than_3_months: bool, + /// When true, host telemetry dashboard is readable without sign-in. + pub public_dashboard: bool, + /// Site IDs that are readable without sign-in. + pub public_site_ids: Vec, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + cleanup_logs_older_than_3_months: false, + public_dashboard: false, + public_site_ids: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublicAccess { + pub dashboard: bool, + pub site_ids: Vec, +} + +impl AppSettings { + pub fn public_access(&self) -> PublicAccess { + PublicAccess { + dashboard: self.public_dashboard, + site_ids: self.public_site_ids.clone(), + } + } + + pub fn has_any_public(&self) -> bool { + self.public_dashboard || !self.public_site_ids.is_empty() + } + + pub fn site_is_public(&self, id: i64) -> bool { + self.public_site_ids.contains(&id) + } +} + +impl Db { + pub fn ensure_settings_table(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + ", + )?; + Ok(()) + } + + fn get_setting_raw(&self, key: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let v = conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |r| r.get::<_, String>(0), + ) + .optional()?; + Ok(v) + } + + fn set_setting_raw(&self, key: &str, value: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + ) + .context("set setting")?; + Ok(()) + } + + pub fn get_settings(&self) -> Result { + let mut s = AppSettings::default(); + if let Some(v) = self.get_setting_raw("cleanup_logs_older_than_3_months")? { + s.cleanup_logs_older_than_3_months = v == "true" || v == "1"; + } + if let Some(v) = self.get_setting_raw("public_dashboard")? { + s.public_dashboard = v == "true" || v == "1"; + } + if let Some(v) = self.get_setting_raw("public_site_ids")? { + s.public_site_ids = serde_json::from_str(&v).unwrap_or_default(); + } + // Drop IDs for sites that no longer exist + let known: std::collections::HashSet = self + .list_health_sites()? + .into_iter() + .map(|site| site.id) + .collect(); + s.public_site_ids.retain(|id| known.contains(id)); + Ok(s) + } + + pub fn save_settings(&self, settings: &AppSettings) -> Result { + let mut clean = settings.clone(); + clean.public_site_ids.sort_unstable(); + clean.public_site_ids.dedup(); + let known: std::collections::HashSet = self + .list_health_sites()? + .into_iter() + .map(|site| site.id) + .collect(); + clean.public_site_ids.retain(|id| known.contains(id)); + + self.set_setting_raw( + "cleanup_logs_older_than_3_months", + if clean.cleanup_logs_older_than_3_months { + "true" + } else { + "false" + }, + )?; + self.set_setting_raw( + "public_dashboard", + if clean.public_dashboard { "true" } else { "false" }, + )?; + self.set_setting_raw( + "public_site_ids", + &serde_json::to_string(&clean.public_site_ids).unwrap_or_else(|_| "[]".into()), + )?; + Ok(clean) + } + + pub fn prune_health_results_older_than(&self, days: i64) -> Result { + let cutoff = chrono::Utc::now().timestamp() - days.max(1) * 86400; + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "DELETE FROM health_results WHERE checked_at < ?1", + params![cutoff], + )?; + Ok(n) + } +} + +use rusqlite::OptionalExtension; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..96b026c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + meterbox: + build: . + container_name: meterbox + restart: unless-stopped + # Host network + bind 127.0.0.1 inside the app: + # - accurate host NIC / load metrics + # - no Docker published ports (avoids DNAT bypass of INPUT REJECT) + # - Caddy/Rathole can still reach 127.0.0.1:8787 + network_mode: host + pid: host + volumes: + - ./data:/app/data + - /etc/os-release:/etc/os-release:ro + - /etc/hostname:/etc/hostname:ro + - /run/bwflow:/run/bwflow:ro + - /var/lib/vnstat:/var/lib/vnstat:ro + - /etc/vnstat.conf:/etc/vnstat.conf:ro + environment: + METERBOX_LISTEN: "127.0.0.1:8787" + METERBOX_DB: "/app/data/meterbox.db" + METERBOX_STATIC: "/app/frontend/dist" + METERBOX_INTERVAL_SECS: "30" + METERBOX_RETENTION_DAYS: "90" + # Hard cap: 10 GiB for SQLite (db + wal + shm). Cleanup job trims oldest rows. + METERBOX_MAX_DB_BYTES: "10737418240" + METERBOX_CLEANUP_INTERVAL_SECS: "3600" + METERBOX_BWFLOW_SOCK: "/run/bwflow/bwflow.sock" + security_opt: + - no-new-privileges:true diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fb371e2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + Meterbox + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..de8273f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1756 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "recharts": "^3.10.1" + }, + "devDependencies": { + "@types/node": "^24.13.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "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" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@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" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "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/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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4379e80 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "recharts": "^3.10.1" + }, + "devDependencies": { + "@types/node": "^24.13.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..c426c24 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,1284 @@ +.shell { + max-width: 1120px; + margin: 0 auto; + padding: 28px 20px 48px; + display: flex; + flex-direction: column; + gap: 22px; +} + +.app-nav { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 20px; + background: rgba(238, 242, 236, 0.88); + border-bottom: 1px solid var(--stroke); + backdrop-filter: blur(10px); +} + +.app-nav-brand { + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.app-nav-links { + display: inline-flex; + gap: 4px; + padding: 3px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 10px; +} + +.app-nav-links button { + border: none; + background: transparent; + color: var(--muted); + border-radius: 7px; + padding: 6px 12px; + cursor: pointer; + font: inherit; +} + +.app-nav-links button.active { + background: var(--ink); + color: #f4f7f3; +} + +.app-nav-meta { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--muted); +} + +.page-hero { + padding: 8px 4px 0; +} + +.page-hero .brand { + margin: 0; + font-size: clamp(36px, 6vw, 52px); + line-height: 0.95; +} + +.sites-shell { + padding-top: 18px; +} + +.site-name-link { + border: none; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; +} + +.site-name-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.site-dash-hero { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.site-back { + margin-bottom: 10px; +} + +.site-dash-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.site-dash-actions .health-pill.is-up { + background: rgba(63, 125, 78, 0.15); + color: var(--good); +} + +.site-dash-actions .health-pill.is-down { + background: rgba(194, 65, 12, 0.15); + color: var(--accent-2); +} + +.site-dash-actions .health-pill.is-pending { + background: rgba(92, 107, 100, 0.12); + color: var(--muted); +} + +.site-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.site-current-error { + padding: 14px 16px; + border-radius: 12px; + border: 1px solid rgba(194, 65, 12, 0.35); + background: rgba(194, 65, 12, 0.08); +} + +.site-current-error strong { + display: block; + margin-bottom: 6px; + color: var(--accent-2); +} + +.site-current-error p { + margin: 0; + color: var(--ink-soft); +} + +.site-section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 14px; +} + +.site-section header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: baseline; +} + +.site-section h2 { + margin: 0; + font-size: 16px; +} + +.site-section header .mono { + font-size: 12px; + color: var(--muted); +} + +.check-timeline { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-content: flex-start; + min-height: 28px; +} + +.check-tick { + width: 8px; + height: 22px; + border-radius: 2px; +} + +.check-tick.ok { + background: var(--good); + opacity: 0.85; +} + +.check-tick.down { + background: var(--accent-2); +} + +.incident-list, +.check-log { + display: flex; + flex-direction: column; + gap: 10px; +} + +.incident-card { + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.45); +} + +.incident-card.ongoing { + border-color: rgba(194, 65, 12, 0.4); + background: rgba(194, 65, 12, 0.06); +} + +.incident-card header { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.incident-times { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.incident-status { + display: inline-block; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.incident-error { + margin: 0; + color: var(--ink-soft); + font-size: 14px; +} + +.check-row { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.4); +} + +.check-row.down { + border-color: rgba(194, 65, 12, 0.28); +} + +.check-row-head { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + font-size: 12px; + color: var(--muted); +} + +.check-reasons { + margin: 6px 0 0; + padding-left: 18px; + color: var(--ink-soft); + font-size: 13px; +} + +.health-charts { + display: flex; + flex-direction: column; + gap: 8px; +} + +.log-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.log-header > div:first-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.log-filters { + display: inline-flex; + gap: 4px; + padding: 3px; + background: rgba(255, 255, 255, 0.45); + border: 1px solid var(--stroke); + border-radius: 10px; +} + +.log-filters button { + border: none; + background: transparent; + color: var(--muted); + border-radius: 7px; + padding: 5px 10px; + cursor: pointer; + font: inherit; + font-size: 13px; +} + +.log-filters button.active { + background: var(--ink); + color: #f4f7f3; +} + +.log-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: var(--muted); +} + +.check-log.compact { + gap: 6px; +} + +.check-row.compact { + padding: 7px 10px; +} + +.check-error-inline { + flex: 1; + min-width: 12ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-2); +} + +@media (max-width: 860px) { + .site-summary { + grid-template-columns: 1fr 1fr; + } + + .site-dash-hero { + flex-direction: column; + } + + .site-dash-actions { + align-items: flex-start; + } +} + +.setup-gate { + min-height: 100vh; + display: grid; + place-items: center; + padding: 28px 20px; +} + +.setup-card { + width: min(420px, 100%); + display: flex; + flex-direction: column; + gap: 12px; +} + +.setup-card .brand { + margin: 0; + font-size: clamp(36px, 6vw, 48px); + line-height: 0.95; + color: var(--ink); +} + +.guest-continue { + margin-top: 8px; + width: 100%; +} + +.settings-section .settings-hint { + margin: 0 0 10px; + color: var(--ink-soft); + font-size: 14px; +} + +.settings-check { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 0; + border-bottom: 1px solid var(--stroke); + cursor: pointer; +} + +.settings-check:last-child { + border-bottom: none; +} + +.settings-check input { + margin-top: 3px; +} + +.settings-check span { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--ink); +} + +.settings-check small { + color: var(--muted); + font-size: 12px; +} + +.settings-sites h3 { + margin: 14px 0 4px; + font-size: 14px; +} + +.settings-actions { + display: flex; + gap: 10px; +} + +.settings-actions button { + border: none; + background: var(--ink); + color: #f4f7f3; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + font: inherit; +} + +.password-field { + position: relative; + display: block; +} + +.password-field input { + width: 100%; + padding-right: 40px; +} + +.password-toggle { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; +} + +.password-toggle:hover, +.password-toggle[aria-pressed="true"] { + color: var(--ink); + background: rgba(21, 32, 28, 0.06); +} + +.hero { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 24px; + align-items: end; + padding: 8px 4px 4px; +} + +.eyebrow { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + color: var(--muted); +} + +.hero h1 { + margin: 0; + font-size: clamp(42px, 7vw, 64px); + line-height: 0.95; + color: var(--ink); +} + +.lede { + margin: 12px 0 0; + max-width: 36ch; + color: var(--ink-soft); + font-size: 16px; +} + +.layout-hint { + margin: 10px 0 0; + font-size: 11px; + color: var(--muted); +} + +.reset-layout { + grid-column: 1 / -1; + margin-top: 4px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.5); + color: var(--ink-soft); + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + font-size: 11px; + font-family: "IBM Plex Mono", ui-monospace, monospace; +} + +.reset-layout:hover { + background: rgba(255, 255, 255, 0.8); +} + +.sortable-stack { + display: flex; + flex-direction: column; + gap: 22px; +} + +.sortable-block { + position: relative; + display: grid; + grid-template-columns: 28px 1fr; + gap: 8px; + align-items: stretch; +} + +.sortable-block.dragging { + cursor: grabbing; +} + +.drag-handle { + align-self: start; + margin-top: 8px; + width: 28px; + height: 36px; + border: 1px solid var(--stroke); + border-radius: 8px; + background: rgba(255, 255, 255, 0.55); + color: var(--muted); + cursor: grab; + font-size: 14px; + line-height: 1; + letter-spacing: -2px; + padding: 0; + touch-action: none; +} + +.drag-handle:active { + cursor: grabbing; +} + +.sortable-body { + min-width: 0; +} + +.hero-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 18px; + padding: 16px 18px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 14px; + backdrop-filter: blur(8px); +} + +.hero-meta span { + display: block; + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.hero-meta strong { + display: block; + margin-top: 2px; + font-size: 14px; + font-weight: 500; +} + +.live, +.averages { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.stat { + padding: 14px 16px; + border: 1px solid var(--stroke); + border-radius: 12px; + background: rgba(255, 255, 255, 0.42); +} + +.stat-label { + display: block; + font-size: 12px; + color: var(--muted); +} + +.stat-value { + display: block; + margin-top: 4px; + font-size: 22px; + font-weight: 500; + letter-spacing: -0.02em; +} + +.stat-hint { + display: block; + margin-top: 2px; + font-size: 11px; + color: var(--muted); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ranges { + display: inline-flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.5); +} + +.ranges button { + border: 0; + background: transparent; + color: var(--ink-soft); + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + font-weight: 500; +} + +.ranges button.active { + background: var(--ink); + color: #f3f7f1; +} + +.bucket { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +.error { + padding: 12px 14px; + border-radius: 10px; + background: rgba(194, 65, 12, 0.12); + border: 1px solid rgba(194, 65, 12, 0.25); + color: #7c2d12; + font-size: 14px; +} + +.compliance { + display: flex; + flex-direction: column; + gap: 14px; +} + +.compliance-banner { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + padding: 18px 20px; + border-radius: 14px; + border: 1px solid; +} + +.docs-link { + color: inherit; + font-size: 12px; + text-decoration: none; + border-bottom: 1px solid currentColor; + white-space: nowrap; + opacity: 0.85; +} + +.compliance-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.check { + padding: 14px 16px; + border-radius: 12px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.48); +} + +.check header { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; +} + +.check-status { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.check h3 { + margin: 0; + font-family: "Bricolage Grotesque", sans-serif; + font-size: 16px; + letter-spacing: -0.02em; +} + +.check-detail { + margin: 0; + font-size: 13px; + color: var(--ink-soft); + line-height: 1.4; + min-height: 3.6em; +} + +.check-meta { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 10px; + font-size: 11px; + color: var(--muted); +} + +.compliance-notes { + margin: 0; + padding-left: 18px; + color: var(--muted); + font-size: 12px; +} + +.panel-block { + padding: 16px 18px 18px; + border-radius: 14px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.5); +} + +.panel-block-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.bwflow-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.badge { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--stroke); + font-size: 11px; + background: rgba(255, 255, 255, 0.55); + color: var(--ink-soft); +} + +.badge.ok { + border-color: rgba(63, 125, 78, 0.35); + color: #1f4d2a; + background: rgba(63, 125, 78, 0.1); +} + +.badge.warn { + border-color: rgba(194, 65, 12, 0.35); + color: #7c2d12; + background: rgba(194, 65, 12, 0.1); +} + +.bwflow-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.budget-bar { + margin-top: 14px; + height: 10px; + border-radius: 999px; + background: rgba(21, 32, 28, 0.08); + overflow: hidden; +} + +.budget-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 999px; + transition: width 0.4s ease; +} + +.bwflow-months { + margin-top: 16px; +} + +.bwflow-months h3 { + margin: 0 0 8px; + font-family: "Bricolage Grotesque", sans-serif; + font-size: 15px; +} + +.bwflow-months table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.bwflow-months th, +.bwflow-months td { + text-align: left; + padding: 8px 6px; + border-bottom: 1px solid var(--stroke); +} + +.bwflow-months th { + color: var(--muted); + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.bwflow-error { + margin: 0; + color: var(--ink-soft); + font-size: 14px; +} + +.charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.panel { + padding: 16px 16px 8px; + border-radius: 14px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(6px); +} + +.panel.wide { + grid-column: 1 / -1; +} + +.panel header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 4px; +} + +.panel h2 { + margin: 0; + font-family: "Bricolage Grotesque", sans-serif; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.panel header span { + font-size: 11px; + color: var(--muted); +} + +.empty { + height: 220px; + display: grid; + place-items: center; + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 24px; +} + +.foot { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + font-size: 11px; + color: var(--muted); + padding-top: 8px; +} + +@media (max-width: 860px) { + .app-nav { + flex-wrap: wrap; + gap: 10px; + } + + .app-nav-meta { + margin-left: 0; + width: 100%; + justify-content: space-between; + } + + .hero { + grid-template-columns: 1fr; + } + + .live, + .averages { + grid-template-columns: 1fr 1fr; + } + + .compliance-grid { + grid-template-columns: 1fr; + } + + .bwflow-stats { + grid-template-columns: 1fr 1fr; + } + + .charts { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .live, + .averages { + grid-template-columns: 1fr; + } + + .ranges { + width: 100%; + overflow-x: auto; + } +} + +/* --- Site health checks --------------------------------------------------- */ + +.health-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.health-banner { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + padding: 16px 18px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.health-lede { + margin: 0; + max-width: 62ch; + color: var(--ink-soft); +} + +.health-banner-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.health-tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 12px; + width: fit-content; +} + +.health-tabs button { + border: none; + background: transparent; + color: var(--muted); + border-radius: 8px; + padding: 7px 14px; + cursor: pointer; + font: inherit; +} + +.health-tabs button.active { + background: var(--ink); + color: #f4f7f3; +} + +.health-empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 16px 18px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 14px; +} + +.health-manage { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; +} + +.health-auth, +.health-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + background: var(--panel); + border: 1px solid var(--stroke); + border-radius: 14px; +} + +.health-form h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.health-auth label, +.health-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +.health-auth input, +.health-form input[type="text"], +.health-form input[type="number"], +.health-form input:not([type]) { + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.7); + border-radius: 8px; + padding: 8px 10px; + color: var(--ink); + font: inherit; +} + +.health-form-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.health-check { + flex-direction: row !important; + align-items: center; + gap: 8px !important; +} + +.health-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; +} + +.health-form-actions button[type="submit"], +.health-auth button[type="submit"] { + border: none; + background: var(--ink); + color: #f4f7f3; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; +} + +.ghost-btn { + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.55); + color: var(--ink-soft); + border-radius: 8px; + padding: 7px 12px; + cursor: pointer; + font-size: 12px; +} + +.ghost-btn:hover { + background: rgba(255, 255, 255, 0.85); +} + +.ghost-btn.danger { + color: #9f1239; +} + +.health-layout { + display: block; +} + +.health-sites { + display: flex; + flex-direction: column; + gap: 10px; +} + +.health-site { + padding: 14px 16px; + border: 1px solid var(--stroke); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); +} + +.health-site.is-up { + border-color: rgba(63, 125, 78, 0.35); +} + +.health-site.is-down { + border-color: rgba(185, 28, 28, 0.4); + background: rgba(185, 28, 28, 0.05); +} + +.health-site header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.health-site h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.health-url { + color: var(--accent); + font-size: 12px; + word-break: break-all; + text-decoration: none; +} + +.health-pill { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.65); +} + +.health-site.is-up .health-pill { + color: #1f4d2a; + border-color: rgba(63, 125, 78, 0.4); +} + +.health-site.is-down .health-pill { + color: #7f1d1d; + border-color: rgba(185, 28, 28, 0.45); +} + +.health-site-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 10px; + font-size: 11px; + color: var(--muted); +} + +.health-site-meta .bad, +.probe-head .bad { + color: #9f1239; +} + +.health-error { + margin: 8px 0 0; + font-size: 13px; + color: #9f1239; +} + +.health-site-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.health-notice { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(63, 125, 78, 0.35); + background: rgba(63, 125, 78, 0.1); + color: #1f4d2a; + font-size: 13px; +} + +.probe-box { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--stroke); + background: rgba(255, 255, 255, 0.55); +} + +.probe-box.ok { + border-color: rgba(63, 125, 78, 0.35); +} + +.probe-box.fail { + border-color: rgba(185, 28, 28, 0.4); +} + +.probe-head { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + margin-bottom: 8px; +} + +.probe-box ul { + margin: 0; + padding-left: 18px; + color: var(--ink-soft); + font-size: 13px; +} + +.probe-snippet { + margin: 10px 0 0; + padding: 10px; + max-height: 120px; + overflow: auto; + font-size: 11px; + background: rgba(21, 32, 28, 0.04); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 860px) { + .health-layout, + .health-form-row { + grid-template-columns: 1fr; + } + + .health-banner { + flex-direction: column; + } + + .health-banner-meta { + align-items: flex-start; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..be3a84a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,512 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + api, + type BwflowStats, + type ComplianceReport, + type HostInfo, + type RangeKey, + type Sample, + type SeriesResponse, +} from "./api"; +import { CpuChart, MemChart, NetChart } from "./charts"; +import { BwflowPanel } from "./BwflowPanel"; +import { CompliancePanel } from "./CompliancePanel"; +import { AuthGate, type AuthContext } from "./AuthGate"; +import { SitesPage } from "./SitesPage"; +import { SiteDashboard } from "./SiteDashboard"; +import { SettingsPage } from "./SettingsPage"; +import { useRoute } from "./routing"; +import { formatBytes, formatPct, formatRate, formatUptime } from "./format"; +import "./App.css"; + +const RANGES: { key: RangeKey; label: string }[] = [ + { key: "24h", label: "24 hours" }, + { key: "7d", label: "7 days" }, + { key: "4w", label: "4 weeks" }, + { key: "3m", label: "3 months" }, +]; + +const LAYOUT_KEY = "meterbox-layout-v3"; + +const DEFAULT_ORDER = [ + "live", + "toolbar", + "compliance", + "averages", + "chart-cpu", + "chart-mem", + "chart-net", + "bwflow", +] as const; + +type SectionId = (typeof DEFAULT_ORDER)[number]; + +function loadOrder(): SectionId[] { + try { + const raw = localStorage.getItem(LAYOUT_KEY); + if (!raw) return [...DEFAULT_ORDER]; + const parsed = JSON.parse(raw) as string[]; + const known = new Set(DEFAULT_ORDER); + const next = parsed.filter((id): id is SectionId => known.has(id)); + for (const id of DEFAULT_ORDER) { + if (!next.includes(id)) next.push(id); + } + return next; + } catch { + return [...DEFAULT_ORDER]; + } +} + +function SortableSection({ + id, + children, +}: { + id: SectionId; + children: ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.85 : 1, + zIndex: isDragging ? 5 : undefined, + }; + + return ( +
+ +
{children}
+
+ ); +} + +export default function App() { + return {(ctx) => }; +} + +function AppShell({ ctx }: { ctx: AuthContext }) { + const { user, guest, publicAccess, logout, requestLogin } = ctx; + const [route, navigate] = useRoute(); + + const canDashboard = !guest || publicAccess.dashboard; + const canSites = !guest || publicAccess.site_ids.length > 0; + const navKind = + route.kind === "site" ? "sites" : route.kind === "settings" ? "settings" : route.kind; + + useEffect(() => { + if (guest && route.kind === "settings") { + navigate({ kind: canDashboard ? "dashboard" : "sites" }); + return; + } + if (guest && route.kind === "dashboard" && !publicAccess.dashboard) { + navigate({ kind: canSites ? "sites" : "dashboard" }); + return; + } + if (guest && route.kind === "sites" && publicAccess.site_ids.length === 0) { + navigate({ kind: canDashboard ? "dashboard" : "sites" }); + return; + } + if (guest && route.kind === "site" && !publicAccess.site_ids.includes(route.id)) { + navigate({ kind: canSites ? "sites" : "dashboard" }); + } + }, [guest, route, publicAccess, canDashboard, canSites, navigate]); + + return ( + <> + + {route.kind === "settings" && !guest ? ( + navigate({ kind: "dashboard" })} /> + ) : route.kind === "site" ? ( + navigate({ kind: "sites" })} + /> + ) : route.kind === "sites" ? ( + navigate({ kind: "site", id })} + /> + ) : canDashboard ? ( + + ) : ( +
+

No public pages enabled. Sign in to continue.

+
+ )} + + ); +} + +function NavLink({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Dashboard() { + const [range, setRange] = useState("24h"); + const [host, setHost] = useState(null); + const [snap, setSnap] = useState(null); + const [series, setSeries] = useState(null); + const [compliance, setCompliance] = useState(null); + const [bwflow, setBwflow] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [order, setOrder] = useState(loadOrder); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + useEffect(() => { + localStorage.setItem(LAYOUT_KEY, JSON.stringify(order)); + }, [order]); + + useEffect(() => { + let cancelled = false; + async function loadStatic() { + try { + const [h, s, c, b] = await Promise.all([ + api.host(), + api.snapshot(), + api.compliance(), + api.bwflow(), + ]); + if (!cancelled) { + setHost(h); + setSnap(s); + setCompliance(c); + setBwflow(b); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load host"); + } + } + loadStatic(); + const id = window.setInterval(async () => { + try { + const [s, c, b] = await Promise.all([api.snapshot(), api.compliance(), api.bwflow()]); + if (!cancelled) { + setSnap(s); + setCompliance(c); + setBwflow(b); + } + } catch { + /* ignore transient */ + } + }, 15000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + + useEffect(() => { + let cancelled = false; + async function loadSeries() { + setLoading(true); + setError(null); + try { + const data = await api.series(range); + if (!cancelled) setSeries(data); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load series"); + } finally { + if (!cancelled) setLoading(false); + } + } + loadSeries(); + const id = window.setInterval(loadSeries, 60000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [range]); + + const avg = series?.averages; + + function onDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + setOrder((items) => { + const oldIndex = items.indexOf(active.id as SectionId); + const newIndex = items.indexOf(over.id as SectionId); + if (oldIndex < 0 || newIndex < 0) return items; + return arrayMove(items, oldIndex, newIndex); + }); + } + + const sections = useMemo(() => { + const map: Record = { + live: ( +
+ + + 0 + ? `${formatBytes(snap.disk_used_bytes)} / ${formatBytes(snap.disk_total_bytes)}` + : "root volume" + } + /> + + +
+ ), + toolbar: ( +
+
+ {RANGES.map((r) => ( + + ))} +
+

+ {series + ? `avg over ${range} · bucket ${series.bucket_secs}s · ${series.points.length} pts` + : loading + ? "loading…" + : "no data yet"} +

+
+ ), + compliance: compliance ? : null, + averages: ( +
+ + + + + +
+ ), + "chart-cpu": ( +
+
+

CPU utilization

+ percent busy +
+ {series && series.points.length > 0 ? ( + + ) : ( + + )} +
+ ), + "chart-mem": ( +
+
+

Memory used

+ percent of RAM +
+ {series && series.points.length > 0 ? ( + + ) : ( + + )} +
+ ), + "chart-net": ( +
+
+

Network throughput

+ bytes/sec · primary interfaces +
+ {series && series.points.length > 0 ? ( + + ) : ( + + )} +
+ ), + bwflow: bwflow ? : null, + }; + return map; + }, [avg, bwflow, compliance, loading, range, series, snap]); + + return ( +
+
+
+

host telemetry

+

Meterbox

+

+ CPU, memory, boot disk, and network for this machine — last 24 hours through 3 months. +

+

Drag ⋮⋮ to rearrange panels · layout saved in this browser

+
+
+
+ host + {host?.hostname ?? "…"} +
+
+ cores + {host?.cpus ?? "—"} +
+
+ ram + {host ? formatBytes(host.mem_total_bytes) : "—"} +
+
+ disk + + {host && host.disk_total_bytes > 0 + ? `${formatPct(host.disk_pct)} · ${formatBytes(host.disk_total_bytes)}` + : "—"} + +
+
+ uptime + {host ? formatUptime(host.uptime_secs) : "—"} +
+ +
+
+ + {error &&
{error}
} + + + +
+ {order.map((id) => { + const body = sections[id]; + if (!body) return null; + return ( + + {body} + + ); + })} +
+
+
+ +
+ {host?.os ?? ""} + kernel {host?.kernel ?? "—"} + binds 127.0.0.1 by default · proxy via Caddy/Rathole +
+
+ ); +} + +function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+ {label} + {value} + {hint ? {hint} : null} +
+ ); +} + +function Empty({ loading }: { loading: boolean }) { + return ( +
+ {loading + ? "Fetching series…" + : "No samples in this window yet. Meterbox collects every 30s — give it a minute."} +
+ ); +} diff --git a/frontend/src/AuthGate.tsx b/frontend/src/AuthGate.tsx new file mode 100644 index 0000000..b18f5d5 --- /dev/null +++ b/frontend/src/AuthGate.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from "react"; +import { api, type PublicAccess, type UserPublic } from "./api"; +import { PasswordField } from "./PasswordField"; + +type Phase = "loading" | "setup" | "login" | "ready"; + +export type AuthContext = { + user: UserPublic | null; + guest: boolean; + publicAccess: PublicAccess; + logout: () => Promise; + requestLogin: () => void; +}; + +/** First-run setup, then login — or guest access when public pages are enabled. */ +export function AuthGate({ children }: { children: (ctx: AuthContext) => ReactNode }) { + const [phase, setPhase] = useState("loading"); + const [user, setUser] = useState(null); + const [publicAccess, setPublicAccess] = useState({ + dashboard: false, + site_ids: [], + }); + const [forceLogin, setForceLogin] = useState(false); + const [bootError, setBootError] = useState(null); + + const refresh = useCallback(async () => { + const status = await api.authStatus(); + setPublicAccess(status.public); + if (status.needs_setup) { + setUser(null); + setPhase("setup"); + return; + } + if (status.user) { + setUser(status.user); + setForceLogin(false); + setPhase("ready"); + return; + } + setUser(null); + const canGuest = + status.public.dashboard || status.public.site_ids.length > 0; + if (canGuest && !forceLogin) { + setPhase("ready"); + return; + } + setPhase("login"); + }, [forceLogin]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + await refresh(); + } catch (e) { + if (!cancelled) { + setBootError(e instanceof Error ? e.message : "Could not check auth"); + setPhase("login"); + } + } + })(); + return () => { + cancelled = true; + }; + }, [refresh]); + + async function logout() { + await api.logout(); + setUser(null); + setForceLogin(false); + await refresh(); + } + + if (phase === "loading") { + return ( +
+

Checking Meterbox setup…

+
+ ); + } + + if (phase === "setup") { + return ( + { + await refresh(); + }} + /> + ); + } + + if (phase === "login") { + return ( + 0} + onGuest={() => { + setForceLogin(false); + setPhase("ready"); + }} + onDone={async () => { + setBootError(null); + setForceLogin(false); + await refresh(); + }} + /> + ); + } + + return ( + <> + {children({ + user, + guest: user == null, + publicAccess, + logout, + requestLogin: () => { + setForceLogin(true); + setPhase("login"); + }, + })} + + ); +} + +function SetupForm({ onDone }: { onDone: () => Promise }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function submit(e: FormEvent) { + e.preventDefault(); + setError(null); + if (password !== confirm) { + setError("Passwords do not match"); + return; + } + setBusy(true); + try { + await api.setup(email.trim(), password); + await onDone(); + } catch (err) { + setError(err instanceof Error ? err.message : "Setup failed"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

first run

+

Create admin

+

+ Set the email and password for the single account that manages this Meterbox. You only do + this once. +

+
void submit(ev)}> + + setPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + required + /> + setConfirm(e.target.value)} + autoComplete="new-password" + minLength={8} + required + /> + + + {error &&
{error}
} +
+
+ ); +} + +function LoginForm({ + onDone, + onGuest, + allowGuest, + bootError, +}: { + onDone: () => Promise; + onGuest: () => void; + allowGuest: boolean; + bootError: string | null; +}) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(bootError); + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + await api.login(email.trim(), password); + await onDone(); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

meterbox

+

Sign in

+

+ Sign in with the admin account to manage this Meterbox dashboard. +

+
void submit(ev)}> + + setPassword(e.target.value)} + autoComplete="current-password" + minLength={8} + required + /> + + + {allowGuest ? ( + + ) : null} + {error &&
{error}
} +
+
+ ); +} diff --git a/frontend/src/BwflowPanel.tsx b/frontend/src/BwflowPanel.tsx new file mode 100644 index 0000000..12354a9 --- /dev/null +++ b/frontend/src/BwflowPanel.tsx @@ -0,0 +1,128 @@ +import type { BwflowStats } from "./api"; +import { formatBytes } from "./format"; + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +export function BwflowPanel({ stats }: { stats: BwflowStats }) { + if (!stats.available) { + return ( +
+
+

bwflow

+ unavailable +
+

+ {stats.error ?? "bwflow daemon socket not reachable"} + · {stats.socket} +

+
+ ); + } + + const pct = stats.budget_used_pct ?? 0; + const throttled = stats.rate_limit_on === true; + const period = + stats.calendar_month && stats.period_start && stats.period_resets + ? `${stats.calendar_month} · ${stats.period_start} → resets ${stats.period_resets}` + : "calendar month"; + + return ( +
+
+
+

+ outbound budget · calendar month +

+

+ bwflow +

+

+ {period} +

+
+
+ + {throttled ? `throttled ${stats.throttle_kbps} kbps` : "rate limit off"} + + {stats.interface} +
+
+ +
+
+ Egress this month + + {(stats.tx_gb ?? 0).toFixed(2)} GB + + of {stats.monthly_budget_gb} GB budget +
+
+ Tokens left + + {(stats.tokens_remaining_gb ?? 0).toFixed(1)} GB + + pre-charge {stats.pre_charge_gb} GB +
+
+ Budget used + {pct.toFixed(2)}% + as of {stats.as_of ?? "—"} +
+
+ State + {stats.state} + {formatBytes(stats.tx_bytes ?? 0)} TX +
+
+ +
+
+
+

+ Outbound counters use the calendar month and reset on the 1st. Soft budget{" "} + {stats.monthly_budget_gb} GB (of 10 TB monthly target). +

+ + {stats.months.length > 0 && ( +
+

Calendar-month traffic

+ + + + + + + + + + + {[...stats.months].reverse().map((m) => ( + + + + + + + ))} + +
MonthEgress ↑Ingress ↓Total
+ {MONTHS[(m.month - 1) % 12]} {m.year} + {formatBytes(m.tx_bytes)}{formatBytes(m.rx_bytes)}{formatBytes(m.rx_bytes + m.tx_bytes)}
+
+ )} +
+ ); +} diff --git a/frontend/src/CompliancePanel.tsx b/frontend/src/CompliancePanel.tsx new file mode 100644 index 0000000..0393c3b --- /dev/null +++ b/frontend/src/CompliancePanel.tsx @@ -0,0 +1,72 @@ +import type { CSSProperties } from "react"; +import type { ComplianceReport } from "./api"; + +const statusColor: Record = { + pass: "#3f7d4e", + fail: "#b91c1c", + warn: "#c2410c", + info: "#5c6b64", +}; + +const overallStyle: Record = { + compliant: { background: "rgba(63,125,78,0.12)", borderColor: "rgba(63,125,78,0.35)", color: "#1f4d2a" }, + at_risk: { background: "rgba(194,65,12,0.12)", borderColor: "rgba(194,65,12,0.35)", color: "#7c2d12" }, + over_limit: { background: "rgba(185,28,28,0.12)", borderColor: "rgba(185,28,28,0.4)", color: "#7f1d1d" }, +}; + +export function CompliancePanel({ report }: { report: ComplianceReport }) { + const banner = overallStyle[report.overall] ?? overallStyle.at_risk; + const label = + report.overall === "compliant" + ? "On target" + : report.overall === "over_limit" + ? "Over target" + : "Needs attention"; + + return ( +
+
+
+

+ capacity & budgets +

+

+ {label} +

+

{report.summary}

+ {report.tier ? ( +

+ {report.tier} +

+ ) : null} +
+
+ +
+ {report.checks.map((c) => ( +
+
+ + {c.status} + +

{c.label}

+
+

{c.detail}

+
+ now {c.current} + {c.limit} +
+
+ ))} +
+ + {report.notes.length > 0 && ( +
    + {report.notes.map((n) => ( +
  • {n}
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/HealthPanel.tsx b/frontend/src/HealthPanel.tsx new file mode 100644 index 0000000..916b8f6 --- /dev/null +++ b/frontend/src/HealthPanel.tsx @@ -0,0 +1,464 @@ +import { useCallback, useEffect, useState } from "react"; +import { api, type HealthSite, type ProbeResult } from "./api"; + +type FormState = { + name: string; + url: string; + interval_secs: number; + timeout_ms: number; + expected_status: number; + expect_body_contains: string; + enabled: boolean; +}; + +type HealthTab = "sites" | "manage"; + +const emptyForm = (): FormState => ({ + name: "", + url: "https://", + interval_secs: 60, + timeout_ms: 10000, + expected_status: 200, + expect_body_contains: "", + enabled: true, +}); + +export function HealthPanel({ + onOpenSite, + readOnly = false, +}: { + onOpenSite: (id: number) => void; + readOnly?: boolean; +}) { + const [tab, setTab] = useState("sites"); + const [sites, setSites] = useState([]); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [busy, setBusy] = useState(false); + const [probeBusy, setProbeBusy] = useState(false); + const [error, setError] = useState(null); + const [probe, setProbe] = useState(null); + const [notice, setNotice] = useState(null); + + const refreshSites = useCallback(async () => { + try { + setSites(await api.listSites()); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load sites"); + } + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + await refreshSites(); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load sites"); + } + })(); + return () => { + cancelled = true; + }; + }, [refreshSites]); + + useEffect(() => { + const id = window.setInterval(() => { + void refreshSites(); + }, 20000); + return () => window.clearInterval(id); + }, [refreshSites]); + + function openAdd() { + setEditingId(null); + setForm(emptyForm()); + setProbe(null); + setError(null); + setNotice(null); + setTab("manage"); + } + + function startEdit(site: HealthSite) { + setEditingId(site.id); + setForm({ + name: site.name, + url: site.url, + interval_secs: site.interval_secs, + timeout_ms: site.timeout_ms, + expected_status: site.expected_status, + expect_body_contains: site.expect_body_contains ?? "", + enabled: site.enabled, + }); + setProbe(null); + setError(null); + setNotice(null); + setTab("manage"); + } + + async function runProbe() { + setProbeBusy(true); + setError(null); + setNotice(null); + try { + const result = await api.probeSite({ + url: form.url.trim(), + expected_status: form.expected_status, + expect_body_contains: form.expect_body_contains.trim() || undefined, + timeout_ms: form.timeout_ms, + }); + setProbe(result); + if (!result.ok) { + setError(result.reasons.join(" · ") || "Probe failed"); + } else { + setNotice("Probe passed — origin reachable, no Cloudflare fallback/challenge."); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Probe failed"); + } finally { + setProbeBusy(false); + } + } + + async function saveSite() { + setBusy(true); + setError(null); + setNotice(null); + try { + const payload = { + name: form.name.trim(), + url: form.url.trim(), + enabled: form.enabled, + interval_secs: form.interval_secs, + timeout_ms: form.timeout_ms, + expected_status: form.expected_status, + expect_body_contains: form.expect_body_contains.trim() || null, + }; + const res = + editingId == null + ? await api.createSite(payload) + : await api.updateSite(editingId, payload); + setProbe(res.probe); + setNotice( + editingId == null + ? `Added ${res.site.name} after passing origin/Cloudflare checks.` + : `Updated ${res.site.name}.`, + ); + setForm(emptyForm()); + setEditingId(null); + await refreshSites(); + setTab("sites"); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setBusy(false); + } + } + + async function removeSite(id: number) { + if (!window.confirm("Remove this health check?")) return; + setBusy(true); + try { + await api.deleteSite(id); + if (editingId === id) { + setEditingId(null); + setForm(emptyForm()); + } + await refreshSites(); + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed"); + } finally { + setBusy(false); + } + } + + async function checkNow(id: number) { + setBusy(true); + setError(null); + try { + const res = await api.checkSite(id); + setProbe(res.probe); + if (!res.probe.ok) setError(res.probe.reasons.join(" · ")); + await refreshSites(); + } catch (e) { + setError(e instanceof Error ? e.message : "Check failed"); + } finally { + setBusy(false); + } + } + + const up = sites.filter((s) => s.last_ok === true).length; + const down = sites.filter((s) => s.last_ok === false).length; + const unknown = sites.filter((s) => s.last_ok == null).length; + + return ( +
+
+
+

status

+

+ {sites.length === 0 + ? "No sites yet" + : down > 0 + ? `${down} down` + : `${up} healthy`} +

+
+
+ + {up} up · {down} down · {unknown} pending + +
+
+ + {!readOnly ? ( +
+ + +
+ ) : null} + + {tab === "sites" || readOnly ? ( +
+ {sites.length === 0 ? ( +
+

No health checks yet.

+ {!readOnly ? ( + + ) : null} +
+ ) : ( + sites.map((site) => ( +
+
+
+

+ +

+ + {site.url} + +
+ {statusLabel(site)} +
+
+ every {site.interval_secs}s + {site.last_latency_ms != null ? `${site.last_latency_ms} ms` : "—"} + + {site.last_status != null ? `HTTP ${site.last_status}` : "no check yet"} + + {site.last_via_cloudflare ? via CF : null} + {site.last_cf_fallback ? CF fallback : null} + {site.last_cf_challenge ? CF challenge : null} +
+ {site.last_error ?

{site.last_error}

: null} +
+ + {!readOnly ? ( + <> + + + + + ) : null} +
+
+ )) + )} +
+ ) : ( +
+
{ + e.preventDefault(); + void saveSite(); + }} + > +

{editingId == null ? "Add site" : "Edit site"}

+ + +
+ + + +
+ + +
+ + + +
+
+ {notice &&
{notice}
} + {error &&
{error}
} + {probe && } +
+ )} + + {tab === "sites" && error &&
{error}
} + {tab === "sites" && probe && } +
+ ); +} + +function siteStatusClass(site: HealthSite): string { + if (site.last_ok === true) return "is-up"; + if (site.last_ok === false) return "is-down"; + return "is-pending"; +} + +function statusLabel(site: HealthSite): string { + if (!site.enabled) return "paused"; + if (site.last_ok === true) return "up"; + if (site.last_ok === false) return "down"; + return "pending"; +} + +function ProbeDetails({ probe }: { probe: ProbeResult }) { + return ( +
+
+ {probe.ok ? "probe ok" : "probe failed"} + HTTP {probe.status || "—"} + {probe.latency_ms} ms + {probe.via_cloudflare ? Cloudflare edge : direct/origin} + {probe.cloudflare_fallback ? fallback : null} + {probe.cloudflare_challenge ? challenge : null} +
+
    + {probe.reasons.map((r) => ( +
  • {r}
  • + ))} +
+ {probe.body_snippet ? ( +
{probe.body_snippet}
+ ) : null} +
+ ); +} diff --git a/frontend/src/PasswordField.tsx b/frontend/src/PasswordField.tsx new file mode 100644 index 0000000..4dd4462 --- /dev/null +++ b/frontend/src/PasswordField.tsx @@ -0,0 +1,61 @@ +import { useState, type InputHTMLAttributes } from "react"; + +type PasswordFieldProps = Omit, "type"> & { + label: string; +}; + +export function PasswordField({ label, id, ...inputProps }: PasswordFieldProps) { + const [show, setShow] = useState(false); + const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-"); + + return ( + + ); +} + +function EyeIcon({ open }: { open: boolean }) { + if (open) { + return ( + + ); + } + return ( + + ); +} diff --git a/frontend/src/SettingsPage.tsx b/frontend/src/SettingsPage.tsx new file mode 100644 index 0000000..db57fe2 --- /dev/null +++ b/frontend/src/SettingsPage.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { api, type AppSettings, type HealthSite } from "./api"; + +export function SettingsPage({ onBack }: { onBack: () => void }) { + const [settings, setSettings] = useState(null); + const [sites, setSites] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [s, list] = await Promise.all([api.getSettings(), api.listSites()]); + if (!cancelled) { + setSettings(s); + setSites(list); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load settings"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + async function save() { + if (!settings) return; + setBusy(true); + setError(null); + setNotice(null); + try { + const saved = await api.saveSettings(settings); + setSettings(saved); + setNotice("Settings saved."); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setBusy(false); + } + } + + function toggleSite(id: number) { + setSettings((prev) => { + if (!prev) return prev; + const has = prev.public_site_ids.includes(id); + return { + ...prev, + public_site_ids: has + ? prev.public_site_ids.filter((x) => x !== id) + : [...prev.public_site_ids, id], + }; + }); + } + + if (!settings && !error) { + return ( +
+

Loading settings…

+
+ ); + } + + if (!settings) { + return ( +
+ +
{error}
+
+ ); + } + + return ( +
+
+
+ +

admin

+

Settings

+

Retention and public read access for Meterbox.

+
+
+ +
+
+

Health logs

+
+ +
+ +
+
+

Public access

+ viewable without sign-in +
+

+ Guests can only view selected pages. Adding, editing, and settings still require admin + sign-in. +

+ + +
+

Sites

+ {sites.length === 0 ? ( +

No sites yet. Add one under Sites first.

+ ) : ( + sites.map((site) => ( + + )) + )} +
+
+ +
+ +
+ {notice &&
{notice}
} + {error &&
{error}
} +
+ ); +} diff --git a/frontend/src/SiteDashboard.tsx b/frontend/src/SiteDashboard.tsx new file mode 100644 index 0000000..40ced63 --- /dev/null +++ b/frontend/src/SiteDashboard.tsx @@ -0,0 +1,358 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + api, + type HealthCheckResult, + type HealthIncident, + type HealthRangeKey, + type SiteHistory, +} from "./api"; +import { HealthUptimeChart } from "./charts"; +import { formatDuration, formatWhen } from "./format"; + +const RANGES: { key: HealthRangeKey; label: string }[] = [ + { key: "1h", label: "1 hour" }, + { key: "12h", label: "12 hours" }, + { key: "24h", label: "24 hours" }, + { key: "7d", label: "7 days" }, + { key: "30d", label: "30 days" }, +]; + +const LOG_PAGE_SIZE = 10; + +type LogFilter = "all" | "not_200" | "down"; + +export function SiteDashboard({ + siteId, + onBack, + readOnly = false, +}: { + siteId: number; + onBack: () => void; + readOnly?: boolean; +}) { + const [range, setRange] = useState("24h"); + const [history, setHistory] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [logFilter, setLogFilter] = useState("all"); + const [logPage, setLogPage] = useState(0); + + const refresh = useCallback(async () => { + try { + setHistory(await api.siteHistory(siteId, range)); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load history"); + } + }, [siteId, range]); + + useEffect(() => { + let cancelled = false; + setHistory(null); + setLogPage(0); + (async () => { + try { + const data = await api.siteHistory(siteId, range); + if (!cancelled) setHistory(data); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load history"); + } + })(); + return () => { + cancelled = true; + }; + }, [siteId, range]); + + useEffect(() => { + const id = window.setInterval(() => { + void refresh(); + }, 20000); + return () => window.clearInterval(id); + }, [refresh]); + + useEffect(() => { + setLogPage(0); + }, [logFilter, range]); + + async function checkNow() { + setBusy(true); + setError(null); + try { + await api.checkSite(siteId); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Check failed"); + } finally { + setBusy(false); + } + } + + const filteredLogs = useMemo(() => { + if (!history) return []; + return history.results.filter((r) => { + if (logFilter === "not_200") return r.status !== 200; + if (logFilter === "down") return !r.ok; + return true; + }); + }, [history, logFilter]); + + const pageCount = Math.max(1, Math.ceil(filteredLogs.length / LOG_PAGE_SIZE)); + const safePage = Math.min(logPage, pageCount - 1); + const pageLogs = filteredLogs.slice( + safePage * LOG_PAGE_SIZE, + safePage * LOG_PAGE_SIZE + LOG_PAGE_SIZE, + ); + + if (!history && !error) { + return ( +
+

Loading site history…

+
+ ); + } + + if (!history) { + return ( +
+ +
{error}
+
+ ); + } + + const { site, summary, incidents, series } = history; + const status = + site.last_ok === true ? "up" : site.last_ok === false ? "down" : "pending"; + + return ( +
+
+
+ +

site dashboard

+

{site.name}

+ + {site.url} + +
+
+ {status} + {!readOnly ? ( + + ) : null} +
+
+ +
+
+ {RANGES.map((r) => ( + + ))} +
+

+ {summary.checks} checks · bucket {history.bucket_secs}s +

+
+ +
+ + + + +
+ + {site.last_ok === false && site.last_error ? ( +
+ Currently down +

{site.last_error}

+
+ ) : null} + +
+
+

Health status

+ {range} +
+ {series.length === 0 ? ( +

No checks in this window yet.

+ ) : ( + + )} +
+ +
+
+

Downtime

+ {incidents.length} events +
+ {incidents.length === 0 ? ( +

No downtime in this window.

+ ) : ( +
+ {incidents.slice(0, 10).map((inc) => ( + + ))} +
+ )} +
+ +
+
+
+

Check log

+ + {filteredLogs.length} shown · page {safePage + 1}/{pageCount} + +
+
+ + + +
+
+ +
+ {pageLogs.length === 0 ? ( +

No matching checks.

+ ) : ( + pageLogs.map((r) => ) + )} +
+ +
+ + + {safePage * LOG_PAGE_SIZE + (pageLogs.length ? 1 : 0)}– + {safePage * LOG_PAGE_SIZE + pageLogs.length} of {filteredLogs.length} + + +
+
+ + {error &&
{error}
} +
+ ); +} + +function SummaryStat({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+ {label} + {value} + {hint} +
+ ); +} + +function IncidentCard({ incident }: { incident: HealthIncident }) { + return ( +
+
+ {incident.ongoing ? "Ongoing outage" : "Outage"} + + {incident.duration_secs != null ? formatDuration(incident.duration_secs) : "—"} + {" · "} + {incident.checks} check{incident.checks === 1 ? "" : "s"} + +
+
+ Down from {formatWhen(incident.started_at)} + + {incident.ended_at ? `Recovered ${formatWhen(incident.ended_at)}` : "Still down"} + +
+ {incident.status != null && incident.status > 0 ? ( + HTTP {incident.status} + ) : null} +

{incident.error}

+
+ ); +} + +function CheckRow({ result }: { result: HealthCheckResult }) { + return ( +
+
+ + {result.ok ? "up" : "down"} + + {formatWhen(result.checked_at)} + {result.latency_ms} ms + {result.status > 0 ? `HTTP ${result.status}` : "—"} + {!result.ok && result.error ? ( + + {result.error} + + ) : null} +
+
+ ); +} diff --git a/frontend/src/SitesPage.tsx b/frontend/src/SitesPage.tsx new file mode 100644 index 0000000..351d859 --- /dev/null +++ b/frontend/src/SitesPage.tsx @@ -0,0 +1,25 @@ +import { HealthPanel } from "./HealthPanel"; + +export function SitesPage({ + onOpenSite, + readOnly = false, +}: { + onOpenSite: (id: number) => void; + readOnly?: boolean; +}) { + return ( +
+
+
+

monitoring

+

Sites

+

+ HTTP health checks from this host. Open a site for its uptime timeline and downtime + history. +

+
+
+ +
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..d50f4ea --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,347 @@ +export type RangeKey = "24h" | "7d" | "4w" | "3m"; + +export type HostInfo = { + hostname: string; + os: string; + kernel: string; + cpus: number; + mem_total_bytes: number; + disk_used_bytes: number; + disk_total_bytes: number; + disk_pct: number; + uptime_secs: number; +}; + +export type Sample = { + ts: number; + cpu_pct: number; + mem_used_bytes: number; + mem_total_bytes: number; + mem_pct: number; + disk_used_bytes: number; + disk_total_bytes: number; + disk_pct: number; + net_rx_bps: number; + net_tx_bps: number; + load1: number; + load5: number; + load15: number; +}; + +export type SeriesPoint = { + ts: number; + cpu_pct: number; + mem_pct: number; + mem_used_bytes: number; + mem_total_bytes: number; + disk_pct: number; + disk_used_bytes: number; + disk_total_bytes: number; + net_rx_bps: number; + net_tx_bps: number; + load1: number; +}; + +export type SeriesResponse = { + range: string; + from_ts: number; + to_ts: number; + bucket_secs: number; + points: SeriesPoint[]; + averages: { + cpu_pct: number; + mem_pct: number; + disk_pct: number; + net_rx_bps: number; + net_tx_bps: number; + load1: number; + }; +}; + +export type ComplianceCheck = { + id: string; + label: string; + status: "pass" | "fail" | "warn" | "info" | string; + detail: string; + current: string; + limit: string; +}; + +export type ComplianceReport = { + overall: "compliant" | "at_risk" | "over_limit" | string; + summary: string; + tier: string; + docs_url: string; + notes: string[]; + checks: ComplianceCheck[]; + host: { + ocpus: number; + mem_total_bytes: number; + disk_total_bytes: number; + arch: string; + is_arm: boolean; + }; +}; + +export type BwflowMonth = { + year: number; + month: number; + rx_bytes: number; + tx_bytes: number; +}; + +export type BwflowStats = { + available: boolean; + error: string | null; + socket: string; + state: string | null; + interface: string | null; + as_of: string | null; + tx_bytes: number | null; + tokens_bytes: number | null; + throttle_kbps: number | null; + monthly_budget_gb: number | null; + pre_charge_gb: number | null; + budget_used_pct: number | null; + tokens_remaining_gb: number | null; + tx_gb: number | null; + rate_limit_on: boolean | null; + calendar_month: string | null; + period_start: string | null; + period_resets: string | null; + months: BwflowMonth[]; +}; + +export type UserPublic = { + id: number; + email: string; + created_at: number; +}; + +export type AuthStatus = { + needs_setup: boolean; + user: UserPublic | null; + public: PublicAccess; +}; + +export type PublicAccess = { + dashboard: boolean; + site_ids: number[]; +}; + +export type AppSettings = { + cleanup_logs_older_than_3_months: boolean; + public_dashboard: boolean; + public_site_ids: number[]; +}; + +export type AuthSession = { + token: string; + user: UserPublic; +}; + +export type HealthSite = { + id: number; + name: string; + url: string; + enabled: boolean; + interval_secs: number; + timeout_ms: number; + expected_status: number; + expect_body_contains: string | null; + created_at: number; + updated_at: number; + last_check_at: number | null; + last_ok: boolean | null; + last_status: number | null; + last_latency_ms: number | null; + last_error: string | null; + last_via_cloudflare: boolean | null; + last_cf_fallback: boolean | null; + last_cf_challenge: boolean | null; +}; + +export type UpsertSite = { + name: string; + url: string; + enabled?: boolean; + interval_secs?: number; + timeout_ms?: number; + expected_status?: number; + expect_body_contains?: string | null; +}; + +export type ProbeResult = { + ok: boolean; + url: string; + final_url: string; + status: number; + latency_ms: number; + server: string | null; + via_cloudflare: boolean; + cloudflare_fallback: boolean; + cloudflare_challenge: boolean; + body_snippet: string; + error: string | null; + reasons: string[]; +}; + +export type SiteCreateResponse = { + site: HealthSite; + probe: ProbeResult; +}; + +export type HealthCheckResult = { + id: number; + site_id: number; + checked_at: number; + ok: boolean; + status: number; + latency_ms: number; + via_cloudflare: boolean; + cf_fallback: boolean; + cf_challenge: boolean; + error: string | null; + reasons: string[]; +}; + +export type HealthIncident = { + started_at: number; + ended_at: number | null; + duration_secs: number | null; + ongoing: boolean; + error: string; + status: number | null; + checks: number; +}; + +export type HealthSeriesPoint = { + ts: number; + uptime_pct: number; + ok: number; + down: number; + avg_latency_ms: number; + had_down: number; +}; + +export type HealthRangeKey = "1h" | "12h" | "24h" | "7d" | "30d"; + +export type SiteHistory = { + site: HealthSite; + range: HealthRangeKey | string; + from_ts: number; + to_ts: number; + bucket_secs: number; + summary: { + checks: number; + ok: number; + down: number; + uptime_pct: number | null; + }; + incidents: HealthIncident[]; + series: HealthSeriesPoint[]; + results: HealthCheckResult[]; +}; + +const TOKEN_KEY = "meterbox-token"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +async function getJson(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (init?.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const res = await fetch(path, { ...init, headers }); + if (!res.ok) { + if (res.status === 401) setToken(null); + let detail = `${path} failed: ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body.error) detail = body.error; + } catch { + /* ignore */ + } + throw new Error(detail); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export const api = { + getToken, + host: () => getJson("/api/host"), + snapshot: () => getJson("/api/snapshot"), + series: (range: RangeKey) => getJson(`/api/series?range=${range}`), + compliance: () => getJson("/api/compliance"), + bwflow: () => getJson("/api/bwflow"), + health: () => + getJson<{ ok: boolean; samples: number; oldest_ts: number | null }>("/api/health"), + authStatus: () => getJson("/api/auth/status"), + setup: async (email: string, password: string) => { + const session = await getJson("/api/auth/setup", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + setToken(session.token); + return session; + }, + login: async (email: string, password: string) => { + const session = await getJson("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + setToken(session.token); + return session; + }, + logout: async () => { + try { + await getJson("/api/auth/logout", { method: "POST" }); + } finally { + setToken(null); + } + }, + listSites: () => getJson("/api/sites"), + getSite: (id: number) => getJson(`/api/sites/${id}`), + siteHistory: (id: number, range: HealthRangeKey | string = "24h") => + getJson(`/api/sites/${id}/history?range=${encodeURIComponent(range)}`), + createSite: (body: UpsertSite) => + getJson("/api/sites", { + method: "POST", + body: JSON.stringify(body), + }), + updateSite: (id: number, body: UpsertSite) => + getJson(`/api/sites/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }), + deleteSite: (id: number) => + getJson(`/api/sites/${id}`, { method: "DELETE" }), + probeSite: (body: { + url: string; + expected_status?: number; + expect_body_contains?: string; + timeout_ms?: number; + }) => + getJson("/api/sites/probe", { + method: "POST", + body: JSON.stringify(body), + }), + checkSite: (id: number) => + getJson(`/api/sites/${id}/check`, { method: "POST" }), + getSettings: () => getJson("/api/settings"), + saveSettings: (body: AppSettings) => + getJson("/api/settings", { + method: "PUT", + body: JSON.stringify(body), + }), +}; diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/charts.tsx b/frontend/src/charts.tsx new file mode 100644 index 0000000..91aff3b --- /dev/null +++ b/frontend/src/charts.tsx @@ -0,0 +1,228 @@ +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { HealthSeriesPoint, SeriesPoint } from "./api"; +import { formatPct, formatRate, formatTick } from "./format"; + +type Props = { + points: SeriesPoint[]; + range: string; +}; + +const tooltipStyle = { + background: "#f7faf5", + border: "1px solid rgba(21,32,28,0.12)", + borderRadius: 8, + fontSize: 12, +}; + +export function CpuChart({ points, range }: Props) { + const data = points.map((p) => ({ + ...p, + label: formatTick(p.ts, range), + })); + return ( + + + + + + + + + + + `${v}%`} + width={44} + /> + [formatPct(Number(value)), "CPU"]} + /> + + + + ); +} + +export function MemChart({ points, range }: Props) { + const data = points.map((p) => ({ + ...p, + label: formatTick(p.ts, range), + })); + return ( + + + + + + + + + + + `${v}%`} + width={44} + /> + [formatPct(Number(value)), "Memory"]} + /> + + + + ); +} + +export function NetChart({ points, range }: Props) { + const data = points.map((p) => ({ + ...p, + label: formatTick(p.ts, range), + })); + return ( + + + + + formatRate(Number(v))} + width={72} + /> + [formatRate(Number(value)), String(name)]} + /> + + + + + + ); +} + +export function HealthUptimeChart({ + points, + range, +}: { + points: HealthSeriesPoint[]; + range: string; +}) { + const data = points.map((p) => ({ + ...p, + label: formatTick(p.ts, range), + status: p.had_down ? 0 : 1, + })); + return ( +
+ + + + + + + + + + + `${v}%`} + width={44} + /> + { + if (String(name) === "Uptime") return [`${Number(value).toFixed(1)}%`, "Uptime"]; + return [String(value), String(name)]; + }} + /> + + + + + + + + (Number(v) === 1 ? "up" : "down")} + width={44} + /> + [Number(value) === 1 ? "up" : "down", "Status"]} + /> + + + +
+ ); +} diff --git a/frontend/src/format.ts b/frontend/src/format.ts new file mode 100644 index 0000000..39e31d0 --- /dev/null +++ b/frontend/src/format.ts @@ -0,0 +1,69 @@ +export function formatBytes(n: number): string { + if (!Number.isFinite(n) || n < 0) return "—"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function formatRate(bps: number): string { + if (!Number.isFinite(bps) || bps < 0) return "—"; + // Show as bits/s style for network familiarity when large, else bytes/s + const bits = bps * 8; + if (bits >= 1_000_000) return `${(bits / 1_000_000).toFixed(2)} Mbit/s`; + if (bits >= 1_000) return `${(bits / 1_000).toFixed(1)} kbit/s`; + return `${bps.toFixed(0)} B/s`; +} + +export function formatPct(n: number): string { + if (!Number.isFinite(n)) return "—"; + return `${n.toFixed(1)}%`; +} + +export function formatUptime(secs: number): string { + const d = Math.floor(secs / 86400); + const h = Math.floor((secs % 86400) / 3600); + const m = Math.floor((secs % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +export function formatTick(ts: number, range: string): string { + const d = new Date(ts * 1000); + if (range === "1h" || range === "12h" || range === "24h") { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + if (range === "7d") { + return d.toLocaleString([], { weekday: "short", hour: "2-digit" }); + } + return d.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +export function formatWhen(ts: number): string { + return new Date(ts * 1000).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +export function formatDuration(secs: number): string { + if (!Number.isFinite(secs) || secs < 0) return "—"; + if (secs < 60) return `${Math.round(secs)}s`; + const m = Math.floor(secs / 60); + const s = Math.round(secs % 60); + if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 48) return rm > 0 ? `${h}h ${rm}m` : `${h}h`; + const d = Math.floor(h / 24); + const rh = h % 24; + return rh > 0 ? `${d}d ${rh}h` : `${d}d`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..3caa5af --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,57 @@ +:root { + --ink: #15201c; + --ink-soft: #2a3a34; + --paper: #e6ebe4; + --paper-2: #d5ddd2; + --panel: rgba(255, 255, 255, 0.55); + --stroke: rgba(21, 32, 28, 0.12); + --accent: #0f766e; + --accent-2: #c2410c; + --good: #3f7d4e; + --muted: #5c6b64; + --chart-cpu: #0f766e; + --chart-mem: #c2410c; + --chart-rx: #1d4ed8; + --chart-tx: #0f766e; + --shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset; + font-family: "IBM Plex Sans", system-ui, sans-serif; + color: var(--ink); + background: var(--paper); + line-height: 1.45; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + min-height: 100vh; + background: + radial-gradient(1200px 600px at 10% -10%, rgba(15, 118, 110, 0.14), transparent 55%), + radial-gradient(900px 500px at 100% 0%, rgba(194, 65, 12, 0.1), transparent 50%), + linear-gradient(180deg, #eef2ec 0%, #dfe6db 100%); +} + +button { + font: inherit; +} + +.mono { + font-family: "IBM Plex Mono", ui-monospace, monospace; +} + +.brand { + font-family: "Bricolage Grotesque", sans-serif; + font-weight: 700; + letter-spacing: -0.03em; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c2a145c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/routing.ts b/frontend/src/routing.ts new file mode 100644 index 0000000..6ae14db --- /dev/null +++ b/frontend/src/routing.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +export type AppRoute = + | { kind: "dashboard" } + | { kind: "sites" } + | { kind: "site"; id: number } + | { kind: "settings" }; + +function pathToRoute(pathname: string): AppRoute { + const path = pathname.replace(/\/+$/, "") || "/"; + const siteMatch = path.match(/^\/sites\/(\d+)$/); + if (siteMatch) { + return { kind: "site", id: Number(siteMatch[1]) }; + } + if (path === "/sites") return { kind: "sites" }; + if (path === "/settings") return { kind: "settings" }; + return { kind: "dashboard" }; +} + +export function routeToPath(route: AppRoute): string { + if (route.kind === "site") return `/sites/${route.id}`; + if (route.kind === "sites") return "/sites"; + if (route.kind === "settings") return "/settings"; + return "/"; +} + +export function useRoute(): [AppRoute, (route: AppRoute) => void] { + const [route, setRouteState] = useState(() => + pathToRoute(window.location.pathname), + ); + + useEffect(() => { + function onPopState() { + setRouteState(pathToRoute(window.location.pathname)); + } + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + function navigate(next: AppRoute) { + const path = routeToPath(next); + if (window.location.pathname !== path) { + window.history.pushState({}, "", path); + } + setRouteState(next); + } + + return [route, navigate]; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a7a373e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + proxy: { + "/api": "http://127.0.0.1:8787", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..fd6abe6 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DOCKER=(docker) +if ! docker info >/dev/null 2>&1; then + DOCKER=(sudo docker) +fi + +"${DOCKER[@]}" compose build "$@" diff --git a/scripts/down.sh b/scripts/down.sh new file mode 100755 index 0000000..c24fa09 --- /dev/null +++ b/scripts/down.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DOCKER=(docker) +if ! docker info >/dev/null 2>&1; then + DOCKER=(sudo docker) +fi + +"${DOCKER[@]}" compose down +echo "meterbox stopped" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..8985579 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +# Supported runtime is Docker only. See scripts/up.sh. +exec "$(cd "$(dirname "$0")" && pwd)/up.sh" diff --git a/scripts/up.sh b/scripts/up.sh new file mode 100755 index 0000000..6f45e28 --- /dev/null +++ b/scripts/up.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DOCKER=(docker) +if ! docker info >/dev/null 2>&1; then + DOCKER=(sudo docker) +fi + +echo "==> iptables snapshot (pre)" +sudo iptables -L -n -v | head -40 || true + +echo "==> building & starting meterbox (Docker, localhost-only)" +"${DOCKER[@]}" compose up -d --build + +echo "==> published / listening check" +ss -tlnp | grep 8787 || true +"${DOCKER[@]}" compose ps + +echo "==> health" +sleep 2 +curl -sf http://127.0.0.1:8787/api/health +echo +curl -sf http://127.0.0.1:8787/api/health | grep -q '"ok":true' + +echo "Meterbox up at http://127.0.0.1:8787 (127.0.0.1 only)"