diff --git a/.env.example b/.env.example index 8511314f..fcbe3075 100644 --- a/.env.example +++ b/.env.example @@ -148,3 +148,13 @@ GITSOURCE_MAX_CLONE_BYTES=104857600 # working pull can be briefly silent while a large layer extracts; raise it on # slow links or heavy local builds. Default 600000 (10 minutes). # SENCHO_COMPOSE_STALL_TIMEOUT_MS=600000 + +# Container-side path to the OpenZFS ARC stats file, for ZFS-aware host memory. +# On ZFS hosts (TrueNAS SCALE, Proxmox, ZFS on Ubuntu/Debian) the ARC cache is +# reclaimable but the kernel reports it as used, which can trigger false +# host-memory alerts. When ARC stats are readable, Sencho adds reclaimable ARC +# back into available memory. Sencho checks this path first, then +# /host/proc/spl/kstat/zfs/arcstats, then /proc/spl/kstat/zfs/arcstats. Set this +# only if your ARC stats live at a non-standard path inside the container. If no +# ARC stats are readable, host memory reporting is unchanged. +# SENCHO_ZFS_ARCSTATS_PATH= diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 28470224..0ff6d128 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ +github: Studio-Saelix custom: - https://buymeacoffee.com/sencho diff --git a/.github/actions/start-app/action.yml b/.github/actions/start-app/action.yml index 640730df..3488aad5 100644 --- a/.github/actions/start-app/action.yml +++ b/.github/actions/start-app/action.yml @@ -101,7 +101,7 @@ runs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81748df1..249fa5b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Build Docker image (validation only) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index f6df1dfe..0c8ef5ab 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 with: platforms: arm64 @@ -101,7 +101,7 @@ jobs: # builds apply. Scanning only amd64 is a defensible proxy for the multi-arch # manifest: distro package CVEs are arch-agnostic. - name: Build dev image for pre-publish scan (amd64, loaded) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false @@ -149,7 +149,7 @@ jobs: exit 1 - name: Build and push dev image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 15ddd986..d1d2e366 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -33,7 +33,6 @@ concurrency: permissions: contents: read - pull-requests: write jobs: push_preview_image: @@ -78,7 +77,7 @@ jobs: ref: refs/pull/${{ inputs.pr_number }}/head - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 with: platforms: arm64 @@ -140,7 +139,7 @@ jobs: done <<< "$TAGS" - name: Build preview image for pre-publish scan (amd64, loaded) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false @@ -180,8 +179,7 @@ jobs: exit 1 - name: Build and push preview image - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true @@ -192,41 +190,3 @@ jobs: cache-to: type=gha,mode=max build-args: | APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} - - - name: Comment on pull request with pull instructions - continue-on-error: true - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - PR_NUMBER: ${{ inputs.pr_number }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - DIGEST: ${{ steps.build.outputs.digest }} - with: - script: | - const prNumber = Number(process.env.PR_NUMBER); - const shortSha = process.env.HEAD_SHA.slice(0, 7); - const digest = process.env.DIGEST; - const body = [ - '## Preview image published', - '', - 'Docker Hub preview tags for this PR:', - '', - '```bash', - `docker pull saelix/sencho:pr-${prNumber}`, - '```', - '', - 'Pinned build:', - '', - '```bash', - `docker pull saelix/sencho:preview-${shortSha}`, - '```', - '', - `Manifest digest: \`${digest}\``, - '', - 'These tags are for pre-merge validation only. They are not cosign-signed and are not suitable as a production pin.', - ].join('\n'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a78bcb4a..2791e154 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -44,7 +44,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 with: platforms: arm64 @@ -118,7 +118,7 @@ jobs: # distro package CVEs are arch-agnostic at the manifest level, and # arch-specific container-relevant CVEs are extraordinarily rare. - name: Build release image for pre-publish scan (amd64, loaded) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false @@ -177,7 +177,7 @@ jobs: - name: Build and push Docker image id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 2c5b774d..1e1e1576 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -76,7 +76,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Build image from main HEAD - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f11ab39f..fc35d3a2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.93.1" + ".": "0.93.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f68d29..03a3cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.93.4](https://github.com/Studio-Saelix/sencho/compare/v0.93.3...v0.93.4) (2026-07-02) + + +### Fixed + +* make host memory usage ZFS ARC-aware ([#1547](https://github.com/Studio-Saelix/sencho/issues/1547)) ([98667e0](https://github.com/Studio-Saelix/sencho/commit/98667e0d6f352daf53c7bdb6dff7b8370be0b809)) + +## [0.93.3](https://github.com/Studio-Saelix/sencho/compare/v0.93.2...v0.93.3) (2026-07-02) + + +### Added + +* add GitHub Sponsors alongside Buy Me a Coffee ([#1535](https://github.com/Studio-Saelix/sencho/issues/1535)) ([7c759ff](https://github.com/Studio-Saelix/sencho/commit/7c759ff2b7614b2c26febe33463d38cd3e90fe14)) + + +### Fixed + +* **deploy-modal:** hide frozen countdown label in inline feedback style ([#1534](https://github.com/Studio-Saelix/sencho/issues/1534)) ([60de0dd](https://github.com/Studio-Saelix/sencho/commit/60de0ddec281187dd9ac6790168551630ebeeb9f)) +* **deps:** patch frontend dompurify and @babel/core advisories ([#1539](https://github.com/Studio-Saelix/sencho/issues/1539)) ([d80538f](https://github.com/Studio-Saelix/sencho/commit/d80538f2541ac08feb7255b47fff30fb4332d72d)) +* reflect toggle state in scheduled task Enabled/Disabled label ([#1533](https://github.com/Studio-Saelix/sencho/issues/1533)) ([90c69b1](https://github.com/Studio-Saelix/sencho/commit/90c69b1ea4135077d501399dd1e668b1b5113fa7)) +* wrap auto-heal policy history in stack monitor sheet ([#1538](https://github.com/Studio-Saelix/sencho/issues/1538)) ([c3dcf31](https://github.com/Studio-Saelix/sencho/commit/c3dcf31cc8012cb891534985a0d678d72d381baa)) + + +### Miscellaneous + +* correct release version after mistagged sponsors PR ([#1537](https://github.com/Studio-Saelix/sencho/issues/1537)) ([ad8d874](https://github.com/Studio-Saelix/sencho/commit/ad8d87429409de7d09ff7e53741d42e2fa4c3930)) + +## [0.93.2](https://github.com/Studio-Saelix/sencho/compare/v0.93.1...v0.93.2) (2026-06-30) + + +### Fixed + +* **ci:** correct github-script pin in preview workflow ([#1528](https://github.com/Studio-Saelix/sencho/issues/1528)) ([c9dcc3a](https://github.com/Studio-Saelix/sencho/commit/c9dcc3acf352f5c5cc717dc1d2d6f038832c81cb)) + ## [0.93.1](https://github.com/Studio-Saelix/sencho/compare/v0.93.0...v0.93.1) (2026-06-30) diff --git a/Dockerfile b/Dockerfile index 806a921b..25d8638f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM --platform=$BUILDPLATFORM tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d # Stage 1: Build Frontend # Runs on the BUILD platform (amd64) - frontend has no native modules so the # compiled output (JS/CSS/HTML) is entirely platform-agnostic. -FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS frontend-builder +FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS frontend-builder WORKDIR /app/frontend @@ -22,7 +22,7 @@ RUN npm run build # Stage 2: Compile TypeScript # Runs on the BUILD platform (amd64) - tsc output is platform-agnostic JS. -FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS backend-builder +FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS backend-builder WORKDIR /app/backend @@ -44,7 +44,7 @@ RUN npm run build # tonistiigi/xx + clang as the cross-compiler. # This avoids the Node.js v20 SIGILL crash that occurs when npm runs # under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support. -FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS prod-deps +FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS prod-deps # Copy xx cross-compilation tools into this stage COPY --from=xx / / @@ -237,7 +237,7 @@ RUN test -f /build/docker-compose \ # in this image; operators who want the feature install Trivy on the host # and mount the binary into the container, or run a sidecar. See # docs/operations/trivy-setup.mdx for the supported integration paths. -FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 +FROM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 # Daily cache-bust for the apk upgrade layer. CI passes the current date # (YYYY-MM-DD) as a build-arg, so this RUN layer's hash changes at most diff --git a/README.md b/README.md index d0f904ca..551ad4f5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- - Sencho + + Sencho ### Self-hosted Docker Compose management for one machine or a fleet. @@ -10,7 +10,8 @@ Docs · Website · Discussions · - Sponsor + Sponsor · + Buy Me a Coffee

[![Latest release](https://img.shields.io/github/v/release/studio-saelix/sencho?label=release)](https://github.com/studio-saelix/sencho/releases) diff --git a/SUPPORT.md b/SUPPORT.md index 8b6c7200..83bcb963 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -10,7 +10,7 @@ Sencho is a solo-maintained project. Here is what to expect. - **Feature requests** → [Feature request](https://github.com/studio-saelix/sencho/issues/new?template=feature_request.yml) - **Security vulnerabilities** → [SECURITY.md](SECURITY.md). Do not open a public issue. - **Licensing, billing, refunds** → licensing@sencho.io -- **Support the project** → [Buy Me a Coffee](https://buymeacoffee.com/sencho). Sponsorship is not required and does not change support response times; it helps fund the work. +- **Support the project** → [GitHub Sponsors](https://github.com/sponsors/Studio-Saelix) or [Buy Me a Coffee](https://buymeacoffee.com/sencho). Sponsorship is not required and does not change support response times; it helps fund the work. ## Response times diff --git a/backend/package-lock.json b/backend/package-lock.json index 44f687bd..bb7a9c8a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@aws-sdk/client-s3": "3.1075.0", + "@aws-sdk/client-s3": "3.1077.0", "axios": "^1.15.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", @@ -74,238 +74,17 @@ "@aws-sdk/client-s3": "^3.1037.0" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz", - "integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==", + "version": "3.1000.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.10.tgz", + "integrity": "sha512-OUNjNcyA8Ai2OdlRUxW5jHUf6XJmqqZk3UddL+mDiUCtXrVqdmIHHkdDFWBlBRjhore/3ZBMgRXgcS0ggtvD0Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -313,21 +92,19 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1075.0.tgz", - "integrity": "sha512-JY78kY7zgnu0eP5EhGppC71/8d53ulhT7TI5AlBoQgOEcifwnqZGRzaQQhVkkOKNxb3qNZ3uhoJ7ee7D9CUvJw==", + "version": "3.1077.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1077.0.tgz", + "integrity": "sha512-+vk7zVbLiUaWckFKA834SSiejKLWWNtlG1LP39vqX0mKJ/pHLMjCatL/jNA8vTUGuQe16MZ5ERFehjaBuuePvg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/credential-provider-node": "^3.972.60", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -335,25 +112,22 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1075.0.tgz", - "integrity": "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==", + "version": "3.1077.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1077.0.tgz", + "integrity": "sha512-yWK6jOMrMUgGarXlrQaAopWXva20NaOqTPApuE68SzsBFQ57fQ5E13BKUPLwtPgymk+8L6vG/O4h7Q30IBYr2w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/middleware-flexible-checksums": "^3.974.33", - "@aws-sdk/middleware-sdk-s3": "^3.972.54", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/checksums": "^3.1000.10", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/credential-provider-node": "^3.972.60", + "@aws-sdk/middleware-sdk-s3": "^3.972.56", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -361,18 +135,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", - "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "version": "3.974.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.25.tgz", + "integrity": "sha512-fJFkx6u6wCqGMV/v6EAxiwa2UzEukbvr1hNPv4MrD3yj4IFz011jZg42/eSTOP/u5kJ0tlILqEjCWtT8GiKZvA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.31", + "@aws-sdk/types": "^3.973.14", + "@aws-sdk/xml-builder": "^3.972.32", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.28.0", + "@smithy/signature-v4": "^5.6.0", + "@smithy/types": "^4.15.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -381,16 +155,16 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", - "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.51.tgz", + "integrity": "sha512-Xo+/zf5k5pZdo53X8aVXN4MJGfU/M1P7yMM/GbNY/x9fyRZGEzjhKqW38GA0FSQQ9TYKs+bfPyz5ja4bi6pjTQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -398,18 +172,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", - "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.53.tgz", + "integrity": "sha512-7E9oFUcf9YWe+ttGiWhe/cCSI+pswwelzgQMoKXgPJi1AIfS27TK6et5ZULqEqHu30zbN+jh1RqlwcXqY/aXyg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -417,24 +191,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.56", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", - "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.58.tgz", + "integrity": "sha512-MPr0hD8pyDGfF3dWXvFOILhcKTB9ptqJOJK9JEuDQzpc2HgKisY16eR7IrKUXxSbz8LZj+LHz/CS8Y5G1ai7yw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-login": "^3.972.55", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/credential-provider-env": "^3.972.51", + "@aws-sdk/credential-provider-http": "^3.972.53", + "@aws-sdk/credential-provider-login": "^3.972.57", + "@aws-sdk/credential-provider-process": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.57", + "@aws-sdk/credential-provider-web-identity": "^3.972.57", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/credential-provider-imds": "^4.4.4", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -442,17 +216,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", - "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.57.tgz", + "integrity": "sha512-kPWc/SCrl9agKeywxKwPEoQHanWag0LcNQrcZpEQpjNifkxq6tQENhgrrS9al317CF6yytyihlX+FhPHlk0QjA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -460,22 +234,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", - "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.60.tgz", + "integrity": "sha512-hE2hIBJQjCDRx8TbSqpVQ+/o2mIrJZQZbQ3LlwE2bJf7z47x5GmhcvGwZPqJH7Oq//SzTXEBGSZ4qSpK3yPbhw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-ini": "^3.972.56", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/credential-provider-env": "^3.972.51", + "@aws-sdk/credential-provider-http": "^3.972.53", + "@aws-sdk/credential-provider-ini": "^3.972.58", + "@aws-sdk/credential-provider-process": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.57", + "@aws-sdk/credential-provider-web-identity": "^3.972.57", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/credential-provider-imds": "^4.4.4", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -483,16 +257,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", - "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.51.tgz", + "integrity": "sha512-081dD2RlnmY+G05v6E73KfACvDjPjnttrLjGHE2SSglbID25UcuijbWpL4g+XR5T2Kl4oIJoVBXi64s+2f009Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -500,18 +274,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", - "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.57.tgz", + "integrity": "sha512-dC7ZyX3EHKHLOeVUEDzzGvk0L1s6N06YDrau7P0rGXL/j1cO+DzN2w1x9vcEh7zljVCR3019f5mi1Th+GGTURw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/token-providers": "3.1074.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/token-providers": "3.1077.0", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -519,31 +293,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", - "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.57.tgz", + "integrity": "sha512-HtWM3FV2o7NJFJSUqFLBlxmV9RxQRHpzCvQaP1n1Qo4CxQSvwpJ8ERWHiLqXMFDgDXyELt+EZNFcpG6XQRcJbQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz", - "integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/checksums": "^3.1000.8", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -551,17 +311,17 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz", - "integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==", + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.56.tgz", + "integrity": "sha512-VFLd7bT8ef48H2n2iDrY/w9EPpXLmC0v4NEriXy2vuTgEP/FrKqrcwi7eobhcOrdO37uLbMt5AWZlY55R2/xqg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -569,21 +329,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", - "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "version": "3.997.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.25.tgz", + "integrity": "sha512-VpRQ3wR6l+fwRHV5veJL2ehtyQFrGyH/2CJG9DVtb8H3xyqqnZWSTSrq/CJJ7DvDlDgrPRiW2SkYA8pN6VWCFQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -591,15 +349,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", - "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "version": "3.996.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.37.tgz", + "integrity": "sha512-u8qd064XsHzM0Mk+yH4IPKn/ZC9rdniEKs+neBHNlsPZirw3rcLvmrH4ImoKC4yF7A0I/MbcC3dseARnJLiAhg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.973.14", + "@smithy/signature-v4": "^5.6.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -607,17 +365,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1074.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", - "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "version": "3.1077.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1077.0.tgz", + "integrity": "sha512-sRUkfZ3fpOco95jZHsQUQiXvuIVLvCmWVclFg6dRFDyfsYs6Pdr/NuZ2+yJxeHN+6WAfDh2aZ8nlZntnvuhZUQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -625,26 +383,13 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz", + "integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==", "license": "Apache-2.0", "optional": true, "dependencies": { + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -652,13 +397,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", - "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.32.tgz", + "integrity": "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -1446,13 +1191,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", - "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz", + "integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1461,13 +1205,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", - "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.4.tgz", + "integrity": "sha512-jT0WrDaM88L5na9FX1xRNywCS3B1n75wPY5Ksasjo0PHUtuI7d8FclksN1BbOSYTiaiKxUDqU23nUymH/V+AaQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.26.0", + "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1476,14 +1220,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", - "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.1.tgz", + "integrity": "sha512-fW6l9rWoyk1iyzfuZaERnZLNjB6WIojgGm6Bo9Hpfpy3RUpltjLikNlxTsS/YtxVobcfbCGBuAncREYqT4hvqQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -1491,14 +1235,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.6.tgz", - "integrity": "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz", + "integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -1506,14 +1250,14 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", - "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.0.tgz", + "integrity": "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -1809,9 +1553,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1942,17 +1686,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/type-utils": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1965,7 +1709,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.0", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1981,16 +1725,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -2006,14 +1750,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.0", - "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -2028,14 +1772,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2046,9 +1790,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -2063,15 +1807,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2088,9 +1832,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -2102,16 +1846,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.0", - "@typescript-eslint/tsconfig-utils": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2130,16 +1874,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2154,13 +1898,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3230,9 +2974,9 @@ "license": "MIT" }, "node_modules/cron-parser": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.0.tgz", - "integrity": "sha512-6159NDv4eDOjXYDmMTkvUtaVcIrNZI779ydTMOfDdi9fSjhPqmySx0icCHX2+nOyXgNSw6sFCsgLKxYs/2KPuQ==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz", + "integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==", "license": "MIT", "dependencies": { "luxon": "^3.7.2" @@ -3409,9 +3153,9 @@ } }, "node_modules/dockerode": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.0.tgz", - "integrity": "sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -3572,9 +3316,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", "workspaces": [ @@ -4603,9 +4347,9 @@ "license": "ISC" }, "node_modules/isomorphic-git": { - "version": "1.38.5", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.5.tgz", - "integrity": "sha512-4HBmx1UB+SdRaQh7+zN7lvFQxc4zl7s0oIM/1+5xveu6+QrOeFfx5QDeRNpqCzFHICISEdbK7GilsTsrgE006Q==", + "version": "1.38.6", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.6.tgz", + "integrity": "sha512-lCmZ1m2R0LqbZ4QATtLCxhwNBQPNJc74R81dfyF90yLqP8xs2DZLedcnkVuRj6th/L2HYoIvWP5lBpS1ooAagw==", "license": "MIT", "dependencies": { "async-lock": "^1.4.1", @@ -6496,9 +6240,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.10", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.10.tgz", - "integrity": "sha512-EiBvQfFxZMkNNcfStjxgyy4dxAH+lzaqon9DXiIv6yHBrrdFGChDN1csbJJCBtaM1LWfuik2ZueeeQbRS/1wcA==", + "version": "5.31.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", + "integrity": "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==", "license": "MIT", "os": [ "darwin", @@ -6843,16 +6587,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", - "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/backend/src/__tests__/cache-endpoints.test.ts b/backend/src/__tests__/cache-endpoints.test.ts index 93c1f236..4ee67584 100644 --- a/backend/src/__tests__/cache-endpoints.test.ts +++ b/backend/src/__tests__/cache-endpoints.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb'; +import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock'; import { GitSourceService } from '../services/GitSourceService'; import type { PublicGitSource } from '../services/GitSourceService'; @@ -80,9 +81,13 @@ let tmpDir: string; let app: import('express').Express; let authCookie: string; let CacheService: typeof import('../services/CacheService').CacheService; +let arcFs: ArcstatsFsMock; beforeAll(async () => { tmpDir = await setupTestDb(); + // Host memory reads ZFS ARC stats; intercept those reads so results do not + // depend on whether the CI host is itself ZFS. Default: no ARC present. + arcFs = installArcstatsFsMock(); ({ app } = await import('../index')); ({ CacheService } = await import('../services/CacheService')); @@ -98,6 +103,7 @@ afterAll(() => { beforeEach(() => { CacheService.getInstance().flush(); + arcFs.clear(); mockGetAllContainers.mockReset(); mockGetBulkStackStatuses.mockReset(); @@ -178,8 +184,9 @@ describe('GET /api/system/stats caching', () => { it('reports memory from the active working set, excluding reclaimable cache', async () => { const res = await request(app).get('/api/system/stats').set('Cookie', authCookie); expect(res.status).toBe(200); - // Figures come from mem.active / mem.available (cache-excluded), not the - // cache-inclusive mem.used / mem.free, so a busy host does not read ~100%. + // With no ARC present, effective used is total - available (which equals + // mem.active), not the cache-inclusive mem.used / mem.free, so a busy host + // does not read ~100%. expect(res.body.memory).toMatchObject({ total: 1000, used: 400, // mem.active, not mem.used (500) @@ -187,6 +194,36 @@ describe('GET /api/system/stats caching', () => { usagePercent: '40.0', // 400 / 1000, not 500 / 1000 }); }); + + it('adds reclaimable ZFS ARC back into available memory', async () => { + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 + const res = await request(app).get('/api/system/stats').set('Cookie', authCookie); + expect(res.status).toBe(200); + // available 600 + 200 reclaimable ARC = 800 effective free; used drops to 200. + expect(res.body.memory).toMatchObject({ + total: 1000, + used: 200, + free: 800, + usagePercent: '20.0', + }); + }); +}); + +// ── /api/fleet/overview (local node) ─────────────────────────────────── + +describe('GET /api/fleet/overview local-node memory', () => { + it('reports ARC-adjusted memory for the local node', async () => { + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 + const res = await request(app).get('/api/fleet/overview').set('Cookie', authCookie); + expect(res.status).toBe(200); + const local = res.body.find((n: { type: string }) => n.type === 'local'); + expect(local?.systemStats?.memory).toMatchObject({ + total: 1000, + used: 200, + free: 800, + usagePercent: '20.0', + }); + }); }); // ── /api/stacks/statuses ─────────────────────────────────────────────── diff --git a/backend/src/__tests__/compose-service-match.test.ts b/backend/src/__tests__/compose-service-match.test.ts new file mode 100644 index 00000000..9ca3e9a7 --- /dev/null +++ b/backend/src/__tests__/compose-service-match.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { + containerBelongsToComposeService, + filterContainersByComposeService, +} from '../helpers/composeServiceMatch'; + +describe('composeServiceMatch', () => { + it('matches by Service field from docker compose ps', () => { + const c = { Id: 'abc', Service: 'mariadb', Names: ['/mariadb'] }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + expect(containerBelongsToComposeService(c, 'phpmyadmin')).toBe(false); + }); + + it('matches by com.docker.compose.service label', () => { + const c = { + Id: 'abc', + Names: ['/custom-name'], + Labels: { 'com.docker.compose.service': 'mariadb' }, + }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + }); + + it('matches when container name equals service (container_name in compose)', () => { + const c = { Id: 'abc', Service: '', Names: ['/mariadb'] }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + }); + + it('filterContainersByComposeService returns all replicas', () => { + const containers = [ + { Id: '1', Service: 'app', Names: ['/web-app-1'] }, + { Id: '2', Service: 'app', Names: ['/web-app-2'] }, + { Id: '3', Names: ['/db'] }, + ]; + expect(filterContainersByComposeService(containers, 'app')).toHaveLength(2); + }); +}); diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index e5506574..aa3ff69c 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -19,6 +19,7 @@ const { mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride, mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync, mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts, + mockGetStackContent, mockGetEnvContent, } = vi.hoisted(() => ({ mockSpawn: vi.fn(), mockGetContainersByStack: vi.fn().mockResolvedValue([]), @@ -40,6 +41,8 @@ const { mockGetGlobalSettings: vi.fn().mockReturnValue({}), mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }), mockGetBindMounts: vi.fn().mockResolvedValue(null), + mockGetStackContent: vi.fn().mockResolvedValue(''), + mockGetEnvContent: vi.fn().mockResolvedValue(''), })); vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() })); @@ -109,6 +112,8 @@ vi.mock('../services/FileSystemService', () => ({ restoreStackFiles: mockRestoreStackFiles, getComposeFilename: mockGetComposeFilename, getOverrideFilename: mockGetOverrideFilename, + getStackContent: mockGetStackContent, + getEnvContent: mockGetEnvContent, }), }, })); diff --git a/backend/src/__tests__/containers-route-authz.test.ts b/backend/src/__tests__/containers-route-authz.test.ts index 1dd76e46..2b98ea23 100644 --- a/backend/src/__tests__/containers-route-authz.test.ts +++ b/backend/src/__tests__/containers-route-authz.test.ts @@ -16,6 +16,7 @@ let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; const VIEWER = 'container-read-viewer'; @@ -28,7 +29,7 @@ function viewerToken(): string { } /** - * Replace the DockerController / FileSystemService singletons with stubs so the + * Replace the DockerController / FileSystemService / SelfIdentityService singletons with stubs so the * handlers run without a Docker daemon. The logs stub ends the response itself * (the real streamContainerLogs flushes SSE headers and streams), so a request * that clears the guard resolves instead of hanging. Returns the spies so a test @@ -46,6 +47,11 @@ function stubDockerAndFs(): { docker: ReturnType; fs: ReturnTyp const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ getStacks: vi.fn().mockResolvedValue([]), } as unknown as ReturnType); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: vi.fn().mockReturnValue(false), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); return { docker, fs }; } @@ -54,6 +60,7 @@ beforeAll(async () => { ({ DatabaseService } = await import('../services/DatabaseService')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); + ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); ({ app } = await import('../index')); diff --git a/backend/src/__tests__/containers-route-self-filter.test.ts b/backend/src/__tests__/containers-route-self-filter.test.ts new file mode 100644 index 00000000..611d67ce --- /dev/null +++ b/backend/src/__tests__/containers-route-self-filter.test.ts @@ -0,0 +1,92 @@ +/** + * GET /api/containers must omit Sencho's own container from picker lists. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let DockerController: typeof import('../services/DockerController').default; +let SelfIdentityServiceMod: typeof import('../services/SelfIdentityService').default; + +const VIEWER = 'container-self-filter-viewer'; + +function viewerToken(): string { + const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!; + return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ default: SelfIdentityServiceMod } = await import('../services/SelfIdentityService')); + ({ app } = await import('../index')); + + const hash = await bcrypt.hash('password123', 1); + DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('GET /api/containers self-filter', () => { + beforeEach(() => { + vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: (idOrName: string) => idOrName === 'sencho' || idOrName.startsWith('sencho-id'), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getAllContainers: vi.fn().mockResolvedValue([ + { Id: 'sencho-id-full', Names: ['/sencho'], State: 'running', Status: 'Up 1 day' }, + { Id: 'other-id', Names: ['/mariadb'], State: 'running', Status: 'Up 1 day' }, + ]), + getRunningContainers: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + }); + + it('excludes Sencho from all=true container list', async () => { + const res = await request(app) + .get('/api/containers?all=true') + .set('Authorization', `Bearer ${viewerToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].Names).toEqual(['/mariadb']); + }); + + it('excludes official Sencho images when SelfIdentity does not match by id', async () => { + vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: vi.fn().mockReturnValue(false), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getAllContainers: vi.fn().mockResolvedValue([ + { Id: 'remote-sencho', Names: ['/sencho'], Image: 'saelix/sencho:latest', State: 'running' }, + { Id: 'other-id', Names: ['/mariadb'], State: 'running' }, + ]), + getRunningContainers: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + const res = await request(app) + .get('/api/containers?all=true') + .set('Authorization', `Bearer ${viewerToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].Names).toEqual(['/mariadb']); + }); +}); diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index f9a0ecff..ff3544f0 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -978,6 +978,59 @@ describe('DockerController - inspectImage', () => { }); }); +// --- label / image inspection for the label inventory -------------------------- + +describe('DockerController - inspectImageLabels', () => { + it('returns the image label map', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }), + }); + const dc = DockerController.getInstance(1); + const result = await dc.inspectImageLabels('sha256:img'); + expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } }); + expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img'); + }); + + it('returns null (not a silent empty map) and logs when the image inspect fails', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(new Error('No such image')), + }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const dc = DockerController.getInstance(1); + expect(await dc.inspectImageLabels('missing')).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it('short-circuits an empty image id without inspecting', async () => { + const dc = DockerController.getInstance(1); + expect(await dc.inspectImageLabels('')).toBeNull(); + expect(mockDocker.getImage).not.toHaveBeenCalled(); + }); +}); + +describe('DockerController - inspectContainerLabelsAndImage', () => { + it('returns labels and the image ref from container inspect', async () => { + mockDocker.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }), + }); + const dc = DockerController.getInstance(1); + const result = await dc.inspectContainerLabelsAndImage('c1'); + expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' }); + }); + + it('returns null and logs when the container inspect fails', async () => { + mockDocker.getContainer.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(new Error('no such container')), + }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const dc = DockerController.getInstance(1); + expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); + // --- createNetwork validation -------------------------------------------------- describe('createNetwork', () => { diff --git a/backend/src/__tests__/exclude-self-containers.test.ts b/backend/src/__tests__/exclude-self-containers.test.ts new file mode 100644 index 00000000..a762caa5 --- /dev/null +++ b/backend/src/__tests__/exclude-self-containers.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { isPublishedSenchoImage } from '../helpers/excludeSelfContainers'; + +describe('isPublishedSenchoImage', () => { + it('matches Docker Hub and GHCR release paths', () => { + expect(isPublishedSenchoImage('saelix/sencho:latest')).toBe(true); + expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho:0.93.1')).toBe(true); + expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true); + expect(isPublishedSenchoImage('lscr.io/linuxserver/mariadb:latest')).toBe(false); + }); +}); diff --git a/backend/src/__tests__/fleet-read-authz.test.ts b/backend/src/__tests__/fleet-read-authz.test.ts index 75490854..a61c2255 100644 --- a/backend/src/__tests__/fleet-read-authz.test.ts +++ b/backend/src/__tests__/fleet-read-authz.test.ts @@ -21,6 +21,7 @@ const NODE_READ_ROUTES = [ '/api/fleet/dependency-map', '/api/fleet/networking-summary', '/api/fleet/update-status', + '/api/fleet/container-labels', ]; beforeAll(async () => { @@ -61,4 +62,20 @@ describe('fleet topology reads require node:read', () => { const res = await request(app).get('/api/fleet/overview'); expect(res.status).toBe(401); }); + + it('denies ?reveal=1 on container-labels for a non-admin (viewer)', async () => { + const res = await request(app).get('/api/fleet/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`); + expect(res.status).toBe(403); + }); + + it('denies the node-local /api/system/container-labels for a role without node:read (deployer)', async () => { + const res = await request(app).get('/api/system/container-labels').set('Authorization', `Bearer ${deployerToken}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('denies ?reveal=1 on the node-local container-labels for a non-admin (viewer)', async () => { + const res = await request(app).get('/api/system/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`); + expect(res.status).toBe(403); + }); }); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 09fc0f30..0396f226 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -909,7 +909,9 @@ describe('GitSourceService.createStackFromGit', () => { sha, }); const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + try { const result = await svc.createStackFromGit({ stackName: 'create-happy', repoUrl: 'https://github.com/example/repo.git', @@ -934,6 +936,9 @@ describe('GitSourceService.createStackFromGit', () => { expect(onDisk).toContain('image: nginx'); await cleanupStackDir('create-happy'); + } finally { + validateSpy.mockRestore(); + } }); it('multi-file create then pull reports no local changes (hash is path-independent)', async () => { diff --git a/backend/src/__tests__/helpers/arcstatsFsMock.ts b/backend/src/__tests__/helpers/arcstatsFsMock.ts new file mode 100644 index 00000000..4baf8ffd --- /dev/null +++ b/backend/src/__tests__/helpers/arcstatsFsMock.ts @@ -0,0 +1,98 @@ +import { promises as fs } from 'fs'; +import { vi } from 'vitest'; +import { ARCSTATS_FIXED_PATHS } from '../../helpers/hostMemory'; + +/** + * Path-aware partial mock of `fs.promises` for ZFS arcstats reads. + * + * `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (and optional + * variants) to compute reclaimable ARC. Tests may run on a ZFS host, so a real + * read would make results host-dependent. This installs a spy that intercepts + * ONLY registered/ARC-candidate paths and delegates every other + * `readFile`/`stat` to the real filesystem, so `setupTestDb` and + * `DatabaseService` keep working. Default behavior: ARC candidates reject with + * ENOENT (no ARC), so consumers fall back to the plain `active/total` reading. + */ + +// Sourced from the helper so the mock cannot silently drift from the paths the +// production code actually reads. +export const ARC_CANDIDATE_PATHS = ARCSTATS_FIXED_PATHS; + +/** Second fixed candidate; the default path fixtures are served from. */ +export const DEFAULT_ARC_PATH = ARC_CANDIDATE_PATHS[1]; + +type StatDescriptor = { isFile: boolean; size: number }; + +export interface ArcstatsFsMock { + /** Serve `content` when `path` is read. */ + setRead(path: string, content: string): void; + /** Reject a read of `path` with `err` (e.g. an EACCES/EIO error). */ + setReadError(path: string, err: NodeJS.ErrnoException): void; + /** Control `stat(path)` result (for override-path guard tests). */ + setStat(path: string, descriptor: StatDescriptor | NodeJS.ErrnoException): void; + /** Forget all registered paths (back to default no-ARC). */ + clear(): void; +} + +function enoent(path: string): NodeJS.ErrnoException { + return Object.assign(new Error(`ENOENT: no such file, open '${path}'`), { code: 'ENOENT' }); +} + +/** + * Install the spy. Call once per test file (e.g. in `beforeAll`); use the + * returned setters per test and `clear()` in `beforeEach`. + */ +export function installArcstatsFsMock(): ArcstatsFsMock { + const realReadFile = fs.readFile.bind(fs); + const realStat = fs.stat.bind(fs); + const reads = new Map(); + const stats = new Map(); + const isArcCandidate = (p: string): boolean => ARC_CANDIDATE_PATHS.includes(p); + + vi.spyOn(fs, 'readFile').mockImplementation((async (p: unknown, ...rest: unknown[]) => { + const key = String(p); + if (reads.has(key)) { + const v = reads.get(key)!; + if (v instanceof Error) throw v; + return v; + } + if (isArcCandidate(key)) throw enoent(key); + return (realReadFile as (...a: unknown[]) => unknown)(p, ...rest); + }) as unknown as typeof fs.readFile); + + vi.spyOn(fs, 'stat').mockImplementation((async (p: unknown, ...rest: unknown[]) => { + const key = String(p); + if (stats.has(key)) { + const v = stats.get(key)!; + if (v instanceof Error) throw v; + return { isFile: () => v.isFile, size: v.size }; + } + // A registered read with no explicit stat implies a small regular file. + if (reads.has(key)) { + const v = reads.get(key); + const size = typeof v === 'string' ? Buffer.byteLength(v) : 0; + return { isFile: () => true, size }; + } + if (isArcCandidate(key)) throw enoent(key); + return (realStat as (...a: unknown[]) => unknown)(p, ...rest); + }) as unknown as typeof fs.stat); + + return { + setRead: (path, content) => reads.set(path, content), + setReadError: (path, err) => reads.set(path, err), + setStat: (path, descriptor) => stats.set(path, descriptor), + clear: () => { reads.clear(); stats.clear(); }, + }; +} + +/** Build a minimal arcstats kstat body with the given `size` and `c_min` rows. */ +export function arcstatsBody(sizeRow: string | number, cMinRow: string | number): string { + return [ + 'name type data', + `hits 4 123456`, + `c_min 4 ${cMinRow}`, + `size 4 ${sizeRow}`, + `c_max 4 9999999999`, + '', + ].join('\n'); +} diff --git a/backend/src/__tests__/host-memory.test.ts b/backend/src/__tests__/host-memory.test.ts new file mode 100644 index 00000000..b7c6bf63 --- /dev/null +++ b/backend/src/__tests__/host-memory.test.ts @@ -0,0 +1,204 @@ +/** + * Unit tests for the ZFS ARC-aware host-memory helper. + * + * `adjustForArc` is exercised directly; `readReclaimableArc` and + * `parseArcstats` stay module-internal and are exercised through + * `getHostMemory` with a path-aware fs mock (see helpers/arcstatsFsMock.ts). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { + installArcstatsFsMock, + arcstatsBody, + DEFAULT_ARC_PATH, + ARC_CANDIDATE_PATHS, + type ArcstatsFsMock, +} from './helpers/arcstatsFsMock'; + +const mockMem = vi.fn(); + +vi.mock('systeminformation', () => ({ + default: { mem: (...args: unknown[]) => mockMem(...args) }, +})); + +import { getHostMemory, adjustForArc } from '../helpers/hostMemory'; + +// mem.active === total - available on Linux, so used/free below mirror the +// real systeminformation shape the helper consumes. +const memSample = (total: number, available: number) => ({ + total, + available, + active: total - available, + used: total - available, + free: available, + buffcache: 0, +}); + +let arcFs: ArcstatsFsMock; + +beforeAll(() => { + arcFs = installArcstatsFsMock(); +}); + +beforeEach(() => { + arcFs.clear(); + mockMem.mockReset(); + delete process.env.SENCHO_ZFS_ARCSTATS_PATH; +}); + +describe('adjustForArc', () => { + it('reproduces active/total when reclaimable ARC is 0', () => { + const result = adjustForArc(memSample(1000, 600), 0); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('adds reclaimable ARC back into available, lowering usage', () => { + const result = adjustForArc(memSample(1000, 600), 200); + expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 }); + }); + + it('clamps effective available to total when ARC exceeds the gap', () => { + const result = adjustForArc(memSample(1000, 600), 5000); + expect(result).toEqual({ total: 1000, used: 0, free: 1000, usagePercent: 0 }); + }); + + it('guards against a zero total', () => { + const result = adjustForArc(memSample(0, 0), 0); + expect(result.usagePercent).toBe(0); + }); +}); + +describe('getHostMemory ARC discovery', () => { + it('falls back to active/total when no ARC stats are present', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('subtracts reclaimable ARC (size - c_min) from used', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 }); + }); + + it('prefers the operator override path over the fixed candidates', async () => { + process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats'; + mockMem.mockResolvedValue(memSample(2000, 600)); + arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100)); // reclaimable 400 + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // fixed would be 200 + const result = await getHostMemory(); + expect(result.used).toBe(1000); // 2000 - (600 + 400 override); fixed would give 1200 + expect(result.free).toBe(1000); + }); + + it('reads the host-mounted candidate and prefers it over /proc', async () => { + // ARC_CANDIDATE_PATHS[0] is /host/proc/..., the path docker-compose mounts + // into the container, so this covers the real deployment path and precedence. + mockMem.mockResolvedValue(memSample(2000, 600)); + arcFs.setRead(ARC_CANDIDATE_PATHS[0], arcstatsBody(500, 100)); // /host/proc: reclaimable 400 + arcFs.setRead(ARC_CANDIDATE_PATHS[1], arcstatsBody(300, 100)); // /proc: would be 200 + const result = await getHostMemory(); + expect(result.used).toBe(1000); // 2000 - (600 + 400); /proc winning would give 1200 + }); + + it('falls through to a fixed candidate when the override is unreadable', async () => { + process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setReadError('/custom/arcstats', Object.assign(new Error('nope'), { code: 'ENOENT' })); + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200 + const result = await getHostMemory(); + expect(result.used).toBe(200); + }); + + it('resolves immediately to 0 reclaimable when size < c_min (ARC at floor)', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(50, 100)); // size < c_min + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it.each([ + ['non-numeric size', arcstatsBody('abc', 100)], + ['negative size', arcstatsBody(-5, 100)], + ['non-numeric c_min', arcstatsBody(300, 'xyz')], + ['negative c_min', arcstatsBody(300, -5)], + ['missing c_min', 'size 4 300\n'], + ['missing size', 'c_min 4 100\n'], + ['empty file', ' \n'], + ])('treats a %s record as unusable and yields no ARC', async (_label, body) => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_ARC_PATH, body); + const result = await getHostMemory(); + expect(result.used).toBe(400); // fell through to active/total + }); + + it.each([ + ['EACCES', 'EACCES'], + ['EIO', 'EIO'], + ['EMFILE', 'EMFILE'], + ])('fails open (ARC 0) on a %s read error', async (_label, code) => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setReadError(DEFAULT_ARC_PATH, Object.assign(new Error(code), { code })); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it('logs an unexpected read error (once per code) but stays silent on an expected one', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Expected fs error: silent fall-through. + arcFs.setReadError(ARC_CANDIDATE_PATHS[0], Object.assign(new Error('denied'), { code: 'EACCES' })); + arcFs.setReadError(ARC_CANDIDATE_PATHS[1], Object.assign(new Error('denied'), { code: 'EACCES' })); + await getHostMemory(); + expect(warn).not.toHaveBeenCalled(); + + // Unexpected fs error: logged, but only once per error code across calls. + // Uses a code no other test triggers, since the once-per-code memo is + // process-global. + arcFs.setReadError(ARC_CANDIDATE_PATHS[0], Object.assign(new Error('stale'), { code: 'ESTALE' })); + arcFs.setReadError(ARC_CANDIDATE_PATHS[1], Object.assign(new Error('stale'), { code: 'ESTALE' })); + await getHostMemory(); + await getHostMemory(); + const unexpectedLogs = warn.mock.calls.filter(([msg]) => String(msg).includes('ESTALE')); + expect(unexpectedLogs).toHaveLength(1); + warn.mockRestore(); + }); + + it('skips an override path that is not a regular file', async () => { + process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/arcstats', { isFile: false, size: 10 }); + arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); // override skipped, no fixed ARC present + }); + + it('skips an override path that exceeds the size bound', async () => { + process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/arcstats', { isFile: true, size: 2 * 1024 * 1024 }); + arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it('logs the selected path once and never the file contents', async () => { + process.env.SENCHO_ZFS_ARCSTATS_PATH = '/log-once/arcstats'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead('/log-once/arcstats', arcstatsBody(300, 100)); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + await getHostMemory(); + await getHostMemory(); + const pathLogs = debug.mock.calls.filter(([msg]) => String(msg).includes('/log-once/arcstats')); + expect(pathLogs).toHaveLength(1); + // The log names the path, never the kstat contents (size / c_min values). + expect(String(pathLogs[0][0])).not.toContain('300'); + expect(String(pathLogs[0][0])).not.toContain('100'); + debug.mockRestore(); + }); +}); + +afterEach(() => { + delete process.env.SENCHO_ZFS_ARCSTATS_PATH; +}); diff --git a/backend/src/__tests__/hub-only-guard.test.ts b/backend/src/__tests__/hub-only-guard.test.ts index 6028af5b..939987c0 100644 --- a/backend/src/__tests__/hub-only-guard.test.ts +++ b/backend/src/__tests__/hub-only-guard.test.ts @@ -120,6 +120,26 @@ describe('hubOnlyGuard', () => { expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); }); + it('rejects /api/notification-suppression-rules with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules/') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/notification-suppression-rules (no trailing slash) with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + // Regression for the Global Observability admin gate: the logs feed's // `requireAdmin` lives in the local route handler, which the proxy skips when // forwarding a remote nodeId. Without these prefixes the guard would let the diff --git a/backend/src/__tests__/label-inventory.test.ts b/backend/src/__tests__/label-inventory.test.ts new file mode 100644 index 00000000..2dcac783 --- /dev/null +++ b/backend/src/__tests__/label-inventory.test.ts @@ -0,0 +1,574 @@ +/** + * Label inventory service, provenance, redaction, and GET routes. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { ComposeService } from '../services/ComposeService'; +import DockerController from '../services/DockerController'; +import { DatabaseService } from '../services/DatabaseService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { + buildNodeLabelInventory, + buildStackLabelInventory, +} from '../services/LabelInventoryService'; +import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction'; + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let nodeId: number; + +function composeDir(): string { return process.env.COMPOSE_DIR as string; } + +function writeStack(stack: string, files: Record): void { + const dir = path.join(composeDir(), stack); + fs.mkdirSync(dir, { recursive: true }); + for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content); +} + +function stubRender(serviceLabels: Record> | null): void { + const rendered = serviceLabels === null + ? null + : JSON.stringify({ + name: 'proj', + services: Object.fromEntries( + Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]), + ), + }); + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }), + } as unknown as ComposeService); +} + +interface StubRow { + id: string; + name: string; + state: string; + stack: string | null; + service: string | null; + labels: Record; + inspectFailed?: boolean; + imageId?: string; +} + +/** + * Stub DockerController for the label inventory. `images` maps an image id to its label + * map, or to `null` to simulate an image inspect failure. An image id absent from the map + * inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can + * assert deduplication. + */ +function stubDockerList( + rows: StubRow[], + opts: { images?: Record | null> } = {}, +): { inspectImageLabels: ReturnType } { + const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r })); + const images = opts.images ?? {}; + const inspectImageLabels = vi.fn(async (imageId: string) => { + if (imageId in images) { + const labels = images[imageId]; + return labels === null ? null : { labels }; + } + return { labels: {} }; + }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults), + getContainersByStack: vi.fn().mockImplementation(async (stack: string) => + withDefaults.filter(r => r.stack === stack).map(r => ({ + Id: r.id, + Names: [`/${r.name}`], + State: r.state, + Service: r.service, + })), + ), + inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => { + const row = withDefaults.find(r => r.id === id); + if (!row || row.inspectFailed) return null; + return { labels: row.labels, imageId: row.imageId }; + }), + inspectImageLabels, + } as unknown as DockerController); + return { inspectImageLabels }; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id; +}); + +afterAll(() => cleanupTestDb(tmpDir)); +afterEach(() => vi.restoreAllMocks()); + +describe('buildNodeLabelInventory', () => { + it('builds inverted index and compose-system provenance', async () => { + stubDockerList([ + { + id: 'c1', + name: 'web-1', + state: 'running', + stack: 'mys', + service: 'web', + imageId: 'img1', + labels: { + 'com.docker.compose.service': 'web', + 'traefik.enable': 'true', + }, + }, + ]); + const inv = await buildNodeLabelInventory(nodeId); + expect(inv.containers).toHaveLength(1); + expect(inv.byLabel).toHaveLength(2); + const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service'); + expect(svc?.source).toBe('compose-system'); + const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable'); + expect(traefik?.source).toBe('runtime'); + expect(inv.partial).toBe(false); + }); + + it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => { + stubDockerList([ + { + id: 'c1', + name: 'plex-1', + state: 'running', + stack: 'media', + service: 'plex', + imageId: 'plex-img', + labels: { + 'org.opencontainers.image.title': 'Plex', + 'traefik.enable': 'true', + }, + }, + ], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } }); + const inv = await buildNodeLabelInventory(nodeId); + const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title'); + expect(oci?.source).toBe('image'); + // Same key on the image but a different value: the container overrides it, so runtime. + const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable'); + expect(traefik?.source).toBe('runtime'); + expect(inv.partial).toBe(false); + }); + + it('marks labels unknown and the inventory partial when the image inspect fails', async () => { + stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } }, + ], { images: { broken: null } }); + const inv = await buildNodeLabelInventory(nodeId); + expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown'); + expect(inv.partial).toBe(true); + }); + + it('treats an empty image id as unknown and partial without inspecting an empty id', async () => { + const { inspectImageLabels } = stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } }, + ]); + const inv = await buildNodeLabelInventory(nodeId); + expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown'); + expect(inv.partial).toBe(true); + expect(inspectImageLabels).not.toHaveBeenCalledWith(''); + }); + + it('inspects each shared image only once (dedup)', async () => { + const { inspectImageLabels } = stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } }, + { id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } }, + ], { images: { shared: {} } }); + await buildNodeLabelInventory(nodeId); + const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared'); + expect(sharedCalls).toHaveLength(1); + }); + + it('redacts secret-like label values by default', async () => { + stubDockerList([ + { + id: 'c1', + name: 'app-1', + state: 'running', + stack: 'sec', + service: 'app', + imageId: 'img1', + labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' }, + }, + ]); + const inv = await buildNodeLabelInventory(nodeId); + const token = inv.containers[0].labels.find(l => l.key === 'my.api.token'); + expect(token?.value).toBe(REDACTED_SENTINEL); + expect(token?.redacted).toBe(true); + const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable'); + expect(plain?.value).toBe('true'); + expect(plain?.redacted).toBeUndefined(); + }); + + it('redacts Traefik basicauth and digestauth label values', async () => { + stubDockerList([ + { + id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1', + labels: { + 'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123', + 'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef', + 'traefik.enable': 'true', + }, + }, + ]); + const inv = await buildNodeLabelInventory(nodeId); + const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users')); + const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users')); + expect(basic?.value).toBe(REDACTED_SENTINEL); + expect(basic?.redacted).toBe(true); + expect(digest?.value).toBe(REDACTED_SENTINEL); + expect(digest?.redacted).toBe(true); + expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined(); + }); + + it('reveals secret-like values when revealSecrets is true', async () => { + stubDockerList([ + { + id: 'c1', + name: 'app-1', + state: 'running', + stack: 'sec', + service: 'app', + imageId: 'img1', + labels: { 'api.token': 'visible-when-revealed' }, + }, + ]); + const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true }); + expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed'); + expect(inv.containers[0].labels[0].redacted).toBeUndefined(); + }); +}); + +describe('buildStackLabelInventory', () => { + it('reconciles declared and runtime labels', async () => { + writeStack('lbl1', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n', + }); + stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } }); + stubDockerList([ + { + id: 'c1', + name: 'lbl1-web-1', + state: 'running', + stack: 'lbl1', + service: 'web', + imageId: 'img1', + labels: { + 'traefik.enable': 'true', + 'runtime.only': '1', + 'com.docker.compose.service': 'web', + }, + }, + ]); + const inv = await buildStackLabelInventory(nodeId, 'lbl1'); + expect(inv.renderable).toBe(true); + expect(inv.partial).toBe(false); + const web = inv.services.find(s => s.service === 'web'); + expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']); + expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']); + expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only'); + expect(web?.replicas[0].inBoth).toContain('traefik.enable'); + expect(web?.replicas[0].changed).toEqual([]); + }); + + it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => { + writeStack('drift1', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n', + }); + stubRender({ web: { 'watchtower.enable': 'true' } }); + stubDockerList([ + { + id: 'c1', + name: 'drift1-web-1', + state: 'running', + stack: 'drift1', + service: 'web', + imageId: 'img1', + labels: { 'watchtower.enable': 'false' }, + }, + ]); + const inv = await buildStackLabelInventory(nodeId, 'drift1'); + const web = inv.services.find(s => s.service === 'web'); + expect(web?.replicas[0].changed).toEqual(['watchtower.enable']); + expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable'); + }); + + it('detects drift on a secret-like key while its value stays redacted', async () => { + writeStack('drift2', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n', + }); + stubRender({ web: { 'auth.token': 'declared' } }); + stubDockerList([ + { id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } }, + ]); + const inv = await buildStackLabelInventory(nodeId, 'drift2'); + const web = inv.services.find(s => s.service === 'web'); + expect(web?.replicas[0].changed).toEqual(['auth.token']); + const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token'); + expect(rt?.value).toBe(REDACTED_SENTINEL); + expect(rt?.redacted).toBe(true); + }); + + it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => { + writeStack('fail1', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n', + }); + stubRender({ web: { 'traefik.enable': 'true' } }); + stubDockerList([ + { id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true }, + ]); + const inv = await buildStackLabelInventory(nodeId, 'fail1'); + const web = inv.services.find(s => s.service === 'web'); + expect(web?.replicas[0].inspectFailed).toBe(true); + expect(web?.replicas[0].onlyInCompose).toEqual([]); + expect(web?.replicas[0].runtimeLabels).toEqual([]); + expect(inv.partial).toBe(true); + }); + + it('inspects each shared image only once across replicas (dedup)', async () => { + writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + stubRender({ web: {} }); + const { inspectImageLabels } = stubDockerList([ + { id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } }, + { id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } }, + ], { images: { shared: {} } }); + await buildStackLabelInventory(nodeId, 'ddup'); + expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1); + }); + + it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => { + writeStack('prov1', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n', + }); + stubRender({ web: { foo: 'bar' } }); + stubDockerList([ + { id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } }, + ], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } }); + const inv = await buildStackLabelInventory(nodeId, 'prov1'); + const rep = inv.services.find(s => s.service === 'web')?.replicas[0]; + // foo is on both the image and the Compose file with the same value: Compose wins. + expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose'); + expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image'); + expect(inv.partial).toBe(false); + }); + + it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => { + writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + stubRender({ web: {} }); + stubDockerList([ + { id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } }, + ], { images: { broken: null } }); + const inv = await buildStackLabelInventory(nodeId, 'prov2'); + const rep = inv.services.find(s => s.service === 'web')?.replicas[0]; + expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown'); + expect(inv.partial).toBe(true); + }); + + it('parses list-form compose labels', async () => { + writeStack('lbl2', { + 'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n', + }); + stubRender({ web: { 'watchtower.enable': 'true' } }); + stubDockerList([]); + const inv = await buildStackLabelInventory(nodeId, 'lbl2'); + expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' }); + }); + + it('sets renderable false when compose render fails', async () => { + writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + stubRender(null); + stubDockerList([]); + const inv = await buildStackLabelInventory(nodeId, 'lbl3'); + expect(inv.renderable).toBe(false); + }); + + it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => { + writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + stubRender(null); + stubDockerList([ + { + id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1', + labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' }, + }, + ]); + const inv = await buildStackLabelInventory(nodeId, 'rf1'); + expect(inv.renderable).toBe(false); + // Render failure is signalled by renderable, not partial (which is for inspect failures). + expect(inv.partial).toBe(false); + const rep = inv.services.find(s => s.service === 'web')?.replicas[0]; + expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown'); + expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system'); + expect(rep?.onlyOnContainer).toEqual([]); + expect(rep?.changed).toEqual([]); + }); +}); + +describe('GET /api/system/container-labels', () => { + it('requires authentication', async () => { + const res = await request(app).get('/api/system/container-labels'); + expect(res.status).toBe(401); + }); + + it('returns node inventory', async () => { + stubDockerList([ + { id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } }, + ]); + const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeId).toBe(nodeId); + expect(res.body.containers).toHaveLength(1); + }); + + it('redacts secrets by default and reveals them for an admin with reveal=1', async () => { + stubDockerList([ + { id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } }, + ]); + const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie); + const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token'); + expect(rLabel.value).toBe(REDACTED_SENTINEL); + expect(rLabel.redacted).toBe(true); + + stubDockerList([ + { id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } }, + ]); + const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie); + const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token'); + expect(vLabel.value).toBe('s3cr3t'); + }); +}); + +describe('GET /api/stacks/:stackName/label-inventory', () => { + it('returns 404 for unknown stack', async () => { + const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie); + expect(res.status).toBe(404); + }); + + it('returns stack inventory', async () => { + writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + stubRender({ web: {} }); + stubDockerList([]); + const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.stackName).toBe('route1'); + }); +}); + +describe('GET /api/fleet/container-labels', () => { + it('requires authentication', async () => { + const res = await request(app).get('/api/fleet/container-labels'); + expect(res.status).toBe(401); + }); + + it('aggregates the local node with no node errors', async () => { + stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } }, + { id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } }, + ]); + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors).toEqual({}); + const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label'); + expect(shared).toHaveLength(1); + expect(shared[0].containers).toHaveLength(2); + }); + + it('keeps the same key=value distinct when the source differs', async () => { + stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } }, + { id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } }, + ], { images: { ia: { 'dup.label': 'v' }, ib: {} } }); + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label'); + expect(dup).toHaveLength(2); + // The server sorts by key, value, then source; assert that order directly (no re-sort). + expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']); + }); + + it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => { + stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } }, + ]); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 })); + try { + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors[remoteId]).toBeDefined(); + expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true); + } finally { + db.deleteNode(remoteId); + } + }); + + it('degrades a malformed remote payload into nodeErrors, not a 500', async () => { + stubDockerList([ + { id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } }, + ]); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + // byLabel row missing valid key/value/source: must be rejected by the deep guard. + const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } })); + try { + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors[remoteId]).toBeDefined(); + } finally { + db.deleteNode(remoteId); + } + }); + + it('rejects a remote row with an invalid source value via the source allowlist', async () => { + stubDockerList([]); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } })); + try { + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload'); + } finally { + db.deleteNode(remoteId); + } + }); + + it('degrades a remote with a malformed inventory container into nodeErrors', async () => { + stubDockerList([]); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } })); + try { + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors[remoteId]).toBeDefined(); + } finally { + db.deleteNode(remoteId); + } + }); + + it('rejects a remote row with a malformed nested container ref', async () => { + stubDockerList([]); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } })); + try { + const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.nodeErrors[remoteId]).toBeDefined(); + } finally { + db.deleteNode(remoteId); + } + }); +}); diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 868cac5c..f8a0bcac 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -2,13 +2,15 @@ * Unit tests for MonitorService — alert state machine, metric calculations, * cleanup delegation, global settings evaluation, and concurrency guards. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock'; // ── Hoisted mocks ────────────────────────────────────────────────────── const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContainerMetric, mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs, mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState, + mockPruneScanHistoryPerImage, mockDeleteScansByImageRef, mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream, mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks, mockDispatchAlert, @@ -29,6 +31,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockUpdateStackAlertLastFired: vi.fn(), mockGetSystemState: vi.fn().mockReturnValue(null), mockSetSystemState: vi.fn(), + mockPruneScanHistoryPerImage: vi.fn().mockReturnValue(0), + mockDeleteScansByImageRef: vi.fn().mockReturnValue(0), mockGetRunningContainers: vi.fn().mockResolvedValue([]), mockGetAllContainers: vi.fn().mockResolvedValue([]), mockGetContainerStatsStream: vi.fn().mockResolvedValue('{}'), @@ -63,6 +67,8 @@ vi.mock('../services/DatabaseService', () => ({ updateStackAlertLastFired: mockUpdateStackAlertLastFired, getSystemState: mockGetSystemState, setSystemState: mockSetSystemState, + pruneScanHistoryPerImage: mockPruneScanHistoryPerImage, + deleteScansByImageRef: mockDeleteScansByImageRef, }), }, })); @@ -138,8 +144,16 @@ vi.mock('util', () => ({ import { MonitorService, _resetHostAlertSuppressionStateForTests } from '../services/MonitorService'; +// Host memory now reads ZFS ARC stats; intercept those reads so the suite does +// not depend on whether the machine running it is itself a ZFS host. +let arcFs: ArcstatsFsMock; +beforeAll(() => { + arcFs = installArcstatsFsMock(); +}); + beforeEach(() => { vi.clearAllMocks(); + arcFs.clear(); (MonitorService as any).instance = undefined; _resetHostAlertSuppressionStateForTests(); mockGetSystemState.mockReturnValue(null); @@ -363,6 +377,19 @@ describe('MonitorService - evaluateGlobalSettings', () => { expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory')); }); + it('does not alert when reclaimable ZFS ARC accounts for the memory pressure', async () => { + // Active working set reads 15G/16G (~94%, breaches 80%), but 5G of that is + // reclaimable ARC. Adding ARC back into available drops effective usage to + // ~62.5%, so no host-memory alert should fire. + mockMem.mockResolvedValue(memSample(15e9)); // available 1e9 -> 93.75% + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5e9, 0)); // reclaimable 5e9 + + const svc = MonitorService.getInstance(); + await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' }); + + expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory')); + }); + it('dispatches disk warning when over threshold', async () => { mockFsSize.mockResolvedValue([{ mount: '/', use: 92 }]); diff --git a/backend/src/__tests__/notification-matchers.test.ts b/backend/src/__tests__/notification-matchers.test.ts new file mode 100644 index 00000000..8b00579f --- /dev/null +++ b/backend/src/__tests__/notification-matchers.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { + matchesNotificationFilters, + ruleNeedsStackLabels, + appliesToBell, + appliesToExternal, +} from '../helpers/notificationMatchers'; +import type { NotificationMatchContext } from '../helpers/notificationMatchers'; + +const baseCtx: NotificationMatchContext = { + localNodeId: 1, + stackName: 'my-app', + category: 'monitor_alert', + level: 'error', + stackLabelIds: [10], +}; + +describe('notificationMatchers', () => { + it('matches when all non-empty filters pass', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['my-app'], + label_ids: [10], + categories: ['monitor_alert'], + levels: ['error'], + })).toBe(true); + }); + + it('rejects when node_id does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: 2, + stack_patterns: [], + label_ids: null, + categories: null, + })).toBe(false); + }); + + it('rejects when stack pattern does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['other'], + label_ids: null, + categories: null, + })).toBe(false); + }); + + it('rejects when category does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: ['deploy_success'], + })).toBe(false); + }); + + it('rejects when level does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: ['info'], + })).toBe(false); + }); + + it('matches any when all matchers empty', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: null, + })).toBe(true); + }); + + it('detects when stack labels are needed', () => { + expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true); + expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false); + }); + + it('applies_to helpers', () => { + expect(appliesToBell('bell')).toBe(true); + expect(appliesToBell('external')).toBe(false); + expect(appliesToExternal('external')).toBe(true); + expect(appliesToExternal('bell')).toBe(false); + expect(appliesToBell('both')).toBe(true); + expect(appliesToExternal('both')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 508ecc73..345e5ec1 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -8,12 +8,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockGetEnabledNotificationRoutes, + mockGetEnabledNotificationSuppressionRules, mockGetEnabledAgents, mockGetStackLabelIds, mockAddNotificationHistory, mockUpdateNotificationDispatchError, } = vi.hoisted(() => ({ mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), mockGetEnabledAgents: vi.fn().mockReturnValue([]), mockGetStackLabelIds: vi.fn().mockReturnValue([]), mockAddNotificationHistory: vi.fn().mockReturnValue({ @@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, getEnabledAgents: mockGetEnabledAgents, getStackLabelIds: mockGetStackLabelIds, addNotificationHistory: mockAddNotificationHistory, diff --git a/backend/src/__tests__/notification-suppression-routes-api.test.ts b/backend/src/__tests__/notification-suppression-routes-api.test.ts new file mode 100644 index 00000000..f4334d50 --- /dev/null +++ b/backend/src/__tests__/notification-suppression-routes-api.test.ts @@ -0,0 +1,176 @@ +/** + * Integration tests for notification suppression rules CRUD. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let authCookie: string; +let viewerCookie: string; + +const validBody = { + name: 'Mute staging', + stack_patterns: ['staging'], + categories: ['monitor_alert'], + levels: ['warning'], + applies_to: 'both', + enabled: true, + expires_at: null, +}; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app) + .post('/api/auth/login') + .send({ username: 'viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('Notification suppression - auth enforcement', () => { + it('GET returns 401 without auth', async () => { + const res = await request(app).get('/api/notification-suppression-rules'); + expect(res.status).toBe(401); + }); + + it('GET returns 403 for viewer', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); + + it('POST returns 403 for viewer', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', viewerCookie) + .send(validBody); + expect(res.status).toBe(403); + }); +}); + +describe('Notification suppression - CRUD', () => { + it('POST creates a rule on Community tier', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + expect(res.status).toBe(201); + expect(res.body.name).toBe('Mute staging'); + expect(res.body.applies_to).toBe('both'); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('GET lists rules', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('POST rejects invalid applies_to', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, applies_to: 'invalid' }); + expect(res.status).toBe(400); + }); + + it('POST rejects invalid levels', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, levels: ['critical'] }); + expect(res.status).toBe(400); + }); + + it('POST accepts history-only category update_started (bell-visible)', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ + name: 'Mute stack updates', + stack_patterns: [], + categories: ['update_started'], + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + }); + expect(res.status).toBe(201); + expect(res.body.categories).toEqual(['update_started']); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('POST accepts routable category image_update_available', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ + name: 'Mute image updates', + stack_patterns: [], + categories: ['image_update_available'], + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + }); + expect(res.status).toBe(201); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('PUT updates a rule', async () => { + const created = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + const id = created.body.id as number; + + const res = await request(app) + .put(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(false); + + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + }); + + it('DELETE removes a rule', async () => { + const created = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + const id = created.body.id as number; + + const res = await request(app) + .delete(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie); + expect(res.status).toBe(200); + }); +}); diff --git a/backend/src/__tests__/notification-suppression.test.ts b/backend/src/__tests__/notification-suppression.test.ts new file mode 100644 index 00000000..f465f7f0 --- /dev/null +++ b/backend/src/__tests__/notification-suppression.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for notification suppression in NotificationService.dispatchAlert. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockGetEnabledNotificationRoutes, + mockGetEnabledAgents, + mockGetStackLabelIds, + mockAddNotificationHistory, + mockUpdateNotificationDispatchError, + mockGetEnabledNotificationSuppressionRules, + mockUpdateNotificationSuppressionMatch, + mockBroadcast, +} = vi.hoisted(() => ({ + mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledAgents: vi.fn().mockReturnValue([]), + mockGetStackLabelIds: vi.fn().mockReturnValue([]), + mockAddNotificationHistory: vi.fn().mockReturnValue({ + id: 1, + level: 'error', + message: 'test', + timestamp: Date.now(), + is_read: 0, + }), + mockUpdateNotificationDispatchError: vi.fn(), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), + mockUpdateNotificationSuppressionMatch: vi.fn(), + mockBroadcast: vi.fn(), +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledAgents: mockGetEnabledAgents, + getStackLabelIds: mockGetStackLabelIds, + addNotificationHistory: mockAddNotificationHistory, + updateNotificationDispatchError: mockUpdateNotificationDispatchError, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, + updateNotificationSuppressionMatch: mockUpdateNotificationSuppressionMatch, + }), + }, +})); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDefaultNodeId: () => 1, + getComposeDir: () => '/app/compose', + }), + }, +})); + +const mockFetch = vi.fn().mockResolvedValue({ ok: true }); +vi.stubGlobal('fetch', mockFetch); + +import { NotificationService } from '../services/NotificationService'; +import { StackActivityMetricsService } from '../services/StackActivityMetricsService'; + +function makeSuppressionRule(overrides: Record = {}) { + return { + id: 1, + name: 'Mute crashes', + node_id: null as number | null, + stack_patterns: [] as string[], + label_ids: null as number[] | null, + categories: ['monitor_alert'] as string[] | null, + levels: null as string[] | null, + applies_to: 'both' as const, + enabled: true, + expires_at: null as number | null, + created_at: Date.now(), + updated_at: Date.now(), + ...overrides, + }; +} + +function makeRoute() { + return { + id: 1, + name: 'Prod Discord', + node_id: null, + stack_patterns: ['my-app'], + label_ids: null, + categories: null, + channel_type: 'discord' as const, + channel_url: 'https://discord.com/api/webhooks/123/abc', + priority: 0, + enabled: true, + created_at: Date.now(), + updated_at: Date.now(), + }; +} + +describe('NotificationService - suppression logic', () => { + let svc: NotificationService; + + beforeEach(() => { + vi.clearAllMocks(); + (NotificationService as unknown as { instance?: NotificationService }).instance = undefined; + svc = NotificationService.getInstance(); + vi.spyOn( + svc as unknown as { broadcastToSubscribers: (n: unknown) => void }, + 'broadcastToSubscribers', + ).mockImplementation(mockBroadcast); + vi.spyOn(StackActivityMetricsService.getInstance(), 'record').mockImplementation(() => {}); + }); + + it('suppresses category via external dispatch', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'external' }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('suppresses severity via bell only', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ levels: ['error'], applies_to: 'bell', categories: null }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }); + + expect(mockBroadcast).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('suppresses both bell and external', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'both' }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' }); + + expect(mockBroadcast).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalledWith(1, { + rules: [{ id: 1, name: 'Mute crashes' }], + bellSuppressed: true, + externalSuppressed: true, + }); + }); + + it('allows dispatch when no suppression rules match', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash'); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('routing still works when suppression does not match', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['deploy_success'], applies_to: 'both' }), + ]); + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' }); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); diff --git a/backend/src/__tests__/scheduled-action-registry.test.ts b/backend/src/__tests__/scheduled-action-registry.test.ts index 6ea16769..e56df52f 100644 --- a/backend/src/__tests__/scheduled-action-registry.test.ts +++ b/backend/src/__tests__/scheduled-action-registry.test.ts @@ -19,7 +19,7 @@ const EXPECTED_ACTIONS: BackendScheduledAction[] = [ 'auto_backup', 'auto_stop', 'auto_down', 'auto_start', ]; -const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system']; +const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system', 'container']; describe('scheduledActionRegistry', () => { it('exposes exactly the known backend actions, in order', () => { @@ -46,27 +46,27 @@ describe('scheduledActionRegistry', () => { describe('validateActionTarget', () => { const validPairs: Record = { - restart: ['stack'], + restart: ['stack', 'container'], snapshot: ['fleet'], prune: ['system'], update: ['stack', 'fleet'], scan: ['system'], auto_backup: ['stack'], - auto_stop: ['stack'], + auto_stop: ['stack', 'container'], auto_down: ['stack'], - auto_start: ['stack'], + auto_start: ['stack', 'container'], }; const mismatchMessage: Record = { - restart: 'Restart action requires target_type "stack".', + restart: 'Restart action requires target_type "stack" or "container".', snapshot: 'Snapshot action requires target_type "fleet".', prune: 'Prune action requires target_type "system".', update: 'Update action requires target_type "stack" or "fleet".', scan: 'Scan action requires target_type "system".', auto_backup: 'auto_backup action requires target_type "stack".', - auto_stop: 'auto_stop action requires target_type "stack".', + auto_stop: 'auto_stop action requires target_type "stack" or "container".', auto_down: 'auto_down action requires target_type "stack".', - auto_start: 'auto_start action requires target_type "stack".', + auto_start: 'auto_start action requires target_type "stack" or "container".', }; for (const action of EXPECTED_ACTIONS) { diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 444e5fa9..39d0271b 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -594,6 +594,64 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => { }); }); +describe('POST /api/scheduled-tasks - container lifecycle', () => { + it('creates a container restart schedule', async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'daily-watchtower-restart', + target_type: 'container', + target_id: 'watchtower', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.target_type).toBe('container'); + expect(res.body.target_id).toBe('watchtower'); + expect(res.body.action).toBe('restart'); + }); + + it('rejects invalid container names', async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'bad-container', + target_type: 'container', + target_id: '../escape', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: true, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/valid container name/); + }); + + for (const action of ['auto_stop', 'auto_start'] as const) { + it(`creates container ${action} schedule`, async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: `ctr-${action}`, + target_type: 'container', + target_id: 'sidecar', + node_id: 1, + action, + cron_expression: '0 4 * * *', + enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.target_type).toBe('container'); + expect(res.body.action).toBe(action); + }); + } +}); + describe('POST /api/scheduled-tasks - available on the Community tier', () => { beforeEach(() => { tierSpy.mockReturnValue('community'); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index c482d4de..5d4cd312 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -12,9 +12,10 @@ const { mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode, mockGetGlobalSettings, mockGetStackDossier, mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus, - mockMarkStaleRunsAsFailed, mockDeleteOldScans, + mockMarkStaleRunsAsFailed, mockMarkStaleScansAsFailed, mockDeleteOldScans, mockGetTier, mockGetProxyHeaders, - mockGetContainersByStack, mockRestartContainer, mockPruneSystem, + mockGetContainersByStack, mockRestartContainer, mockFindContainerByName, + mockStartContainer, mockStopContainer, mockPruneSystem, mockUpdateStack, mockGetStacks, mockGetStackContent, mockGetEnvContent, mockCheckImage, @@ -43,11 +44,15 @@ const { mockInsertSnapshotFiles: vi.fn(), mockClearStackUpdateStatus: vi.fn(), mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0), + mockMarkStaleScansAsFailed: vi.fn().mockReturnValue(0), mockDeleteOldScans: vi.fn().mockReturnValue(0), mockGetTier: vi.fn().mockReturnValue('paid'), mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid' }), mockGetContainersByStack: vi.fn().mockResolvedValue([]), mockRestartContainer: vi.fn().mockResolvedValue(undefined), + mockFindContainerByName: vi.fn().mockResolvedValue(null), + mockStartContainer: vi.fn().mockResolvedValue(undefined), + mockStopContainer: vi.fn().mockResolvedValue(undefined), mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }), mockUpdateStack: vi.fn().mockResolvedValue(undefined), mockGetStacks: vi.fn().mockResolvedValue([]), @@ -89,6 +94,7 @@ vi.mock('../services/DatabaseService', () => ({ insertSnapshotFiles: mockInsertSnapshotFiles, clearStackUpdateStatus: mockClearStackUpdateStatus, markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, + markStaleScansAsFailed: mockMarkStaleScansAsFailed, deleteOldScans: mockDeleteOldScans, deleteScheduledTask: mockDeleteScheduledTask, getMatchingPolicy: mockGetMatchingPolicy, @@ -117,6 +123,9 @@ vi.mock('../services/DockerController', () => ({ getInstance: () => ({ getContainersByStack: mockGetContainersByStack, restartContainer: mockRestartContainer, + findContainerByName: mockFindContainerByName, + startContainer: mockStartContainer, + stopContainer: mockStopContainer, pruneSystem: mockPruneSystem, }), }, @@ -530,6 +539,105 @@ describe('SchedulerService - executeRestart', () => { expect.objectContaining({ status: 'failure', error: expect.stringContaining('No containers') }) ); }); + + it('restarts a standalone container by name', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 63, + name: 'restart-ctr', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'watchtower', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName.mockResolvedValue({ + id: 'abc123deadbeef', + name: 'watchtower', + state: 'running', + image: 'containrrr/watchtower', + stackProject: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(63); + + expect(mockFindContainerByName).toHaveBeenCalledWith('watchtower'); + expect(mockRestartContainer).toHaveBeenCalledWith('abc123deadbeef'); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'success', + output: expect.stringContaining('watchtower'), + }), + ); + }); + + it('records failure when container name is missing', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 64, + name: 'restart-missing', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'gone-container', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName.mockResolvedValue(null); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(64); + + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'failure', + error: expect.stringContaining('gone-container'), + }), + ); + }); + + it('resolves a new container ID when the name matches after recreation', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 65, + name: 'restart-recreated', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'sidecar', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName + .mockResolvedValueOnce({ + id: 'old-id-111', + name: 'sidecar', + state: 'running', + image: 'sidecar:latest', + stackProject: null, + }) + .mockResolvedValueOnce({ + id: 'new-id-222', + name: 'sidecar', + state: 'running', + image: 'sidecar:latest', + stackProject: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(65); + await svc.triggerTask(65); + + expect(mockRestartContainer).toHaveBeenNthCalledWith(1, 'old-id-111'); + expect(mockRestartContainer).toHaveBeenNthCalledWith(2, 'new-id-222'); + }); }); // ── executePrune ─────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/stack-service-actions.test.ts b/backend/src/__tests__/stack-service-actions.test.ts index 0bbd3fca..93c79415 100644 --- a/backend/src/__tests__/stack-service-actions.test.ts +++ b/backend/src/__tests__/stack-service-actions.test.ts @@ -140,6 +140,28 @@ describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => { expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1'); expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1'); }); + + it('matches smartFallback containers when Service is empty but container name equals service', async () => { + mockGetContainersByStack.mockResolvedValue([ + { + Id: 'container-mariadb-1', + Service: '', + Names: ['/mariadb'], + State: 'running', + Status: 'Up 12 days', + Ports: [], + }, + makeContainer('container-phpmyadmin-1', 'phpmyadmin'), + ]); + + const res = await request(app) + .post('/api/stacks/db-compose/services/mariadb/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(mockRestartContainer).toHaveBeenCalledWith('container-mariadb-1'); + }); }); describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => { diff --git a/backend/src/helpers/composeServiceMatch.ts b/backend/src/helpers/composeServiceMatch.ts new file mode 100644 index 00000000..c929667c --- /dev/null +++ b/backend/src/helpers/composeServiceMatch.ts @@ -0,0 +1,29 @@ +/** + * Match stack containers to a compose service name. `docker compose ps` sets + * `Service`, but smartFallback containers only had `Names` until Service was + * backfilled; these matchers also accept the compose service label and a + * container name equal to the service (common with `container_name:`). + */ +export interface ComposeServiceContainer { + Id: string; + Service?: string; + Names?: string[]; + Labels?: Record; +} + +export function containerBelongsToComposeService( + container: ComposeServiceContainer, + serviceName: string, +): boolean { + if (container.Service === serviceName) return true; + if (container.Labels?.['com.docker.compose.service'] === serviceName) return true; + const containerName = container.Names?.[0]?.replace(/^\//, ''); + return containerName === serviceName; +} + +export function filterContainersByComposeService( + containers: T[], + serviceName: string, +): T[] { + return containers.filter(c => containerBelongsToComposeService(c, serviceName)); +} diff --git a/backend/src/helpers/excludeSelfContainers.ts b/backend/src/helpers/excludeSelfContainers.ts new file mode 100644 index 00000000..5e1fdd68 --- /dev/null +++ b/backend/src/helpers/excludeSelfContainers.ts @@ -0,0 +1,38 @@ +import SelfIdentityService from '../services/SelfIdentityService'; + +export interface DockerContainerListRow { + Id: string; + Names?: string[]; + Image?: string; + ImageID?: string; + Labels?: Record; +} + +/** Official Sencho release images (Docker Hub + GHCR). Used when SelfIdentity is unavailable on a peer. */ +export function isPublishedSenchoImage(image: string): boolean { + const lower = image.toLowerCase(); + return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower) + || /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower); +} + +function isLikelySenchoManagementContainer(c: DockerContainerListRow): boolean { + const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? ''; + if (name === 'sencho' || name === 'sencho-agent') return true; + if (c.Image && isPublishedSenchoImage(c.Image)) return true; + return false; +} + +/** Drop the running Sencho instance from container picker lists. */ +export async function excludeSelfContainers(containers: T[]): Promise { + const self = SelfIdentityService.getInstance(); + await self.initialize(); + + return containers.filter(c => { + const name = c.Names?.[0]?.replace(/^\//, '') ?? ''; + if (self.isOwnContainer(c.Id)) return false; + if (name && self.isOwnContainer(name)) return false; + if (c.ImageID && self.isOwnImage(c.ImageID)) return false; + if (isLikelySenchoManagementContainer(c)) return false; + return true; + }); +} diff --git a/backend/src/helpers/hostMemory.ts b/backend/src/helpers/hostMemory.ts new file mode 100644 index 00000000..d5858320 --- /dev/null +++ b/backend/src/helpers/hostMemory.ts @@ -0,0 +1,141 @@ +import si from 'systeminformation'; +import { promises as fs } from 'fs'; + +/** + * Shared host-memory computation, ZFS ARC aware. + * + * `systeminformation.mem()` derives `active` as `total - available` on + * Linux/BSD/macOS, so keying usage off `active` already dodges page-cache + * inflation. It does NOT account for the OpenZFS ARC: the kernel's + * MemAvailable treats ARC as unavailable even though ARC shrinks under + * memory pressure, so on ZFS hosts a large ARC reads as hard-used memory + * and produces false host-memory alerts. + * + * When ARC kstats are readable we add the reclaimable portion + * (`max(size - c_min, 0)`) back into available memory. On non-ZFS hosts, or + * when the kstat file is not readable inside the container, ARC is treated as + * zero and the result is identical to the previous `active / total` behavior. + */ + +/** Effective host memory after adding reclaimable ZFS ARC back into available. */ +export interface HostMemory { + total: number; + /** Effective used bytes (ARC-adjusted). */ + used: number; + /** Effective available bytes (ARC-adjusted). */ + free: number; + /** Effective used as a percentage of total (0 when total is 0). */ + usagePercent: number; +} + +type MemData = Awaited>; + +/** + * Candidate arcstats paths in priority order. The operator override is only + * present when SENCHO_ZFS_ARCSTATS_PATH is set; the two fixed paths are the + * host-mounted and the standard container-visible kstat locations. + */ +export const ARCSTATS_FIXED_PATHS = [ + '/host/proc/spl/kstat/zfs/arcstats', + '/proc/spl/kstat/zfs/arcstats', +]; + +/** Bound reads of the operator-supplied override path; arcstats is a few KB. */ +const MAX_ARCSTATS_BYTES = 1024 * 1024; + +// Memoized so a 30s monitor tick / dashboard poll does not log on every cycle. +const loggedSelectedPaths = new Set(); +const loggedErrorCodes = new Set(); + +function overridePath(): string | undefined { + const raw = process.env.SENCHO_ZFS_ARCSTATS_PATH?.trim(); + return raw ? raw : undefined; +} + +function isExpectedFsError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException)?.code; + return ( + code === 'ENOENT' || + code === 'EACCES' || + code === 'EPERM' || + code === 'EISDIR' || + code === 'ENOTDIR' || + code === 'ELOOP' + ); +} + +function logUnexpected(context: string, err: unknown): void { + const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + if (loggedErrorCodes.has(code)) return; + loggedErrorCodes.add(code); + console.warn(`[HostMemory] Unexpected error reading ARC stats (${context}, ${code}); treating ARC as reclaimable=0`); +} + +/** Parse the kstat table for the `size` and `c_min` rows (` `). */ +function parseArcstats(raw: string): { size?: number; cMin?: number } { + let size: number | undefined; + let cMin: number | undefined; + for (const line of raw.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 3) continue; + if (parts[0] === 'size') size = Number(parts[2]); + else if (parts[0] === 'c_min') cMin = Number(parts[2]); + } + return { size, cMin }; +} + +/** + * Reclaimable ARC in bytes, or 0 when ARC stats are unavailable/unusable. + * Never throws: any error resolves to 0 so ARC awareness can only lower a + * false-positive reading, never break host-memory reporting. + */ +async function readReclaimableArc(): Promise { + const override = overridePath(); + const candidates = override ? [override, ...ARCSTATS_FIXED_PATHS] : ARCSTATS_FIXED_PATHS; + for (const candidatePath of candidates) { + try { + // The override path is operator-supplied: verify it is a regular file + // of bounded size before reading (guards against a named pipe or an + // accidentally huge target). The fixed kstat paths are trusted. + if (candidatePath === override) { + const info = await fs.stat(candidatePath); + if (!info.isFile() || info.size > MAX_ARCSTATS_BYTES) continue; + } + const raw = await fs.readFile(candidatePath, 'utf8'); + const { size, cMin } = parseArcstats(raw); + if (size === undefined || cMin === undefined) continue; + if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue; + // A valid record resolves the lookup, even when reclaimable is 0 + // (size < c_min means ARC is at its floor). + if (!loggedSelectedPaths.has(candidatePath)) { + loggedSelectedPaths.add(candidatePath); + console.debug(`[HostMemory] Using ZFS ARC stats from ${candidatePath}`); + } + return Math.max(size - cMin, 0); + } catch (err) { + // Fail open: a missing or unreadable kstat is the normal non-ZFS case + // (expected fs errors); an unexpected error is logged once but still + // falls through so ARC awareness can only lower a false positive. + if (isExpectedFsError(err)) continue; + logUnexpected(candidatePath, err); + } + } + return 0; +} + +/** + * Pure ARC adjustment. With `arcReclaimable === 0` this reproduces the prior + * `active / total` percentage exactly (since `active === total - available`). + */ +export function adjustForArc(mem: Pick, arcReclaimable: number): HostMemory { + const effectiveAvailable = Math.min(mem.total, mem.available + Math.max(arcReclaimable, 0)); + const effectiveUsed = Math.max(mem.total - effectiveAvailable, 0); + const usagePercent = mem.total > 0 ? (effectiveUsed / mem.total) * 100 : 0; + return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent }; +} + +/** Fetch host memory and reclaimable ARC concurrently, return the adjusted view. */ +export async function getHostMemory(): Promise { + const [mem, arcReclaimable] = await Promise.all([si.mem(), readReclaimableArc()]); + return adjustForArc(mem, arcReclaimable); +} diff --git a/backend/src/helpers/labelInventoryRequest.ts b/backend/src/helpers/labelInventoryRequest.ts new file mode 100644 index 00000000..c3958d8a --- /dev/null +++ b/backend/src/helpers/labelInventoryRequest.ts @@ -0,0 +1,18 @@ +import type { Request } from 'express'; +import type { LabelInventoryOptions } from '../services/LabelInventoryService'; +import { requireAdmin } from '../middleware/tierGates'; + +/** Parse ?reveal=1; full values only when the caller is an admin. */ +export function labelInventoryOptionsFromRequest(req: Request): LabelInventoryOptions { + const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true'; + if (!wantsReveal) return { revealSecrets: false }; + // requireAdmin is synchronous guard; routes call it before building inventory when reveal is requested. + return { revealSecrets: true }; +} + +/** Returns false and sends 403 when reveal was requested but caller is not admin. */ +export function requireRevealAdmin(req: Request, res: import('express').Response): boolean { + const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true'; + if (!wantsReveal) return true; + return requireAdmin(req, res); +} diff --git a/backend/src/helpers/labelValueRedaction.ts b/backend/src/helpers/labelValueRedaction.ts new file mode 100644 index 00000000..d6a1c38b --- /dev/null +++ b/backend/src/helpers/labelValueRedaction.ts @@ -0,0 +1,24 @@ +import { isLikelySecretKey } from './secretClassification'; + +export const REDACTED_SENTINEL = '[redacted]'; + +/** + * Compound single-token segments specific to Docker/Compose labels that the generic + * env classifier does not split (e.g. Traefik `basicauth`/`digestauth` middleware keys, + * whose value carries inline `user:passwordhash` credentials). + */ +const SECRET_LABEL_SEGMENTS = new Set(['BASICAUTH', 'DIGESTAUTH']); + +/** True when a Docker/Compose label key likely carries a sensitive value. */ +export function isLikelySecretLabelKey(rawKey: string): boolean { + if (isLikelySecretKey(rawKey)) return true; + const segments = rawKey.trim().toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean); + return segments.some(seg => SECRET_LABEL_SEGMENTS.has(seg)); +} + +export function redactLabelValue(key: string, value: string, revealSecrets: boolean): { value: string; redacted?: boolean } { + if (revealSecrets || !isLikelySecretLabelKey(key)) { + return { value }; + } + return { value: REDACTED_SENTINEL, redacted: true }; +} diff --git a/backend/src/helpers/notificationMatchers.ts b/backend/src/helpers/notificationMatchers.ts new file mode 100644 index 00000000..75c8deb7 --- /dev/null +++ b/backend/src/helpers/notificationMatchers.ts @@ -0,0 +1,68 @@ +import type { NotificationCategory } from '../services/NotificationService'; + +export type NotificationLevel = 'info' | 'warning' | 'error'; +export type NotificationAppliesTo = 'bell' | 'external' | 'both'; + +export interface NotificationFilterRule { + node_id: number | null; + stack_patterns: string[]; + label_ids: number[] | null; + categories: string[] | null; + levels?: NotificationLevel[] | null; +} + +export interface NotificationMatchContext { + localNodeId: number; + stackName?: string; + category: NotificationCategory; + level: NotificationLevel; + stackLabelIds: number[]; +} + +/** True when all non-empty matchers on the rule match the alert context (AND). */ +export function matchesNotificationFilters( + ctx: NotificationMatchContext, + rule: NotificationFilterRule, +): boolean { + if (rule.node_id != null && rule.node_id !== ctx.localNodeId) return false; + if ( + rule.stack_patterns.length > 0 + && (ctx.stackName === undefined || !rule.stack_patterns.includes(ctx.stackName)) + ) { + return false; + } + if ( + rule.label_ids != null + && rule.label_ids.length > 0 + && !rule.label_ids.some((id) => ctx.stackLabelIds.includes(id)) + ) { + return false; + } + if ( + rule.categories != null + && rule.categories.length > 0 + && !rule.categories.includes(ctx.category) + ) { + return false; + } + if ( + rule.levels != null + && rule.levels.length > 0 + && !rule.levels.includes(ctx.level) + ) { + return false; + } + return true; +} + +export function ruleNeedsStackLabels(rules: NotificationFilterRule[]): boolean { + return rules.some((r) => r.label_ids != null && r.label_ids.length > 0); +} + +export function appliesToBell(appliesTo: NotificationAppliesTo): boolean { + return appliesTo === 'bell' || appliesTo === 'both'; +} + +export function appliesToExternal(appliesTo: NotificationAppliesTo): boolean { + return appliesTo === 'external' || appliesTo === 'both'; +} diff --git a/backend/src/helpers/notificationSuppressionSync.ts b/backend/src/helpers/notificationSuppressionSync.ts new file mode 100644 index 00000000..5085a4ca --- /dev/null +++ b/backend/src/helpers/notificationSuppressionSync.ts @@ -0,0 +1,100 @@ +import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { LicenseService } from '../services/LicenseService'; +import { PROXY_TIER_HEADER } from '../services/license-headers'; +import { getErrorMessage } from '../utils/errors'; + +const SYNC_TIMEOUT_MS = 15_000; + +function buildRemoteHeaders(apiToken: string): Record { + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const headers: Record = { + 'Content-Type': 'application/json', + [PROXY_TIER_HEADER]: proxyHeaders.tier, + }; + if (apiToken) headers.Authorization = `Bearer ${apiToken}`; + return headers; +} + +function replicationTargets(rule: NotificationSuppressionRule): Node[] { + const db = DatabaseService.getInstance(); + const remotes = db.getNodes().filter((n) => n.type === 'remote'); + if (rule.node_id != null) { + const target = remotes.find((n) => n.id === rule.node_id); + return target ? [target] : []; + } + return remotes; +} + +async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target?.apiUrl) { + console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`); + return; + } + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, { + method: 'POST', + headers: buildRemoteHeaders(target.apiToken), + body: JSON.stringify({ rule }), + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`); + } +} + +async function deleteRuleOnNode(node: Node, ruleId: number): Promise { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target?.apiUrl) { + console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`); + return; + } + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, { + method: 'DELETE', + headers: buildRemoteHeaders(target.apiToken), + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!res.ok && res.status !== 404) { + const body = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`); + } +} + +/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */ +export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void { + const targets = replicationTargets(rule); + if (targets.length === 0) return; + void Promise.allSettled( + targets.map(async (node) => { + try { + await pushRuleToNode(node, rule); + } catch (err) { + console.error( + `[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`, + getErrorMessage(err, String(err)), + ); + } + }), + ); +} + +/** Best-effort delete of a replicated rule on fleet nodes. */ +export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void { + const targets = replicationTargets(rule); + if (targets.length === 0) return; + void Promise.allSettled( + targets.map(async (node) => { + try { + await deleteRuleOnNode(node, rule.id); + } catch (err) { + console.error( + `[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`, + getErrorMessage(err, String(err)), + ); + } + }), + ); +} diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 7e529c62..49d4c409 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -52,6 +52,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [ '/api/scheduled-tasks/', '/api/audit-log/', '/api/notification-routes/', + '/api/notification-suppression-rules/', '/api/logs/global/', '/api/system/log-stream-metrics/', '/api/registries/', diff --git a/backend/src/helpers/secretClassification.ts b/backend/src/helpers/secretClassification.ts index 7a435cb9..5dd352cd 100644 --- a/backend/src/helpers/secretClassification.ts +++ b/backend/src/helpers/secretClassification.ts @@ -16,6 +16,7 @@ const SECRET_SEGMENTS = new Set([ 'SECRET', 'SECRETS', 'TOKEN', 'KEY', 'APIKEY', 'CREDENTIAL', 'CREDENTIALS', 'AUTH', + 'BASIC', ]); /** Connection strings whose value is sensitive but whose segments are innocuous. */ diff --git a/backend/src/index.ts b/backend/src/index.ts index 4dd10761..bcfdb190 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,7 +36,7 @@ import { agentsRouter } from './routes/agents'; import { metricsRouter } from './routes/metrics'; import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates'; import { autoHealRouter } from './routes/autoHeal'; -import { notificationsRouter, notificationRoutesRouter } from './routes/notifications'; +import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications'; import { consoleRouter } from './routes/console'; import { ssoConfigRouter } from './routes/ssoConfig'; import { registriesRouter } from './routes/registries'; @@ -134,6 +134,7 @@ app.use('/api/auto-update', autoUpdateRouter); app.use('/api/auto-heal', autoHealRouter); app.use('/api/notifications', notificationsRouter); app.use('/api/notification-routes', notificationRoutesRouter); +app.use('/api/notification-suppression-rules', notificationSuppressionRouter); app.use('/api/system', consoleRouter); app.use('/api/sso/config', ssoConfigRouter); app.use('/api/registries', registriesRouter); diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index dea81cb9..b91ba7bf 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; +import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { requireAdmin } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -11,8 +12,11 @@ containersRouter.get('/', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:read')) return; try { const dockerController = DockerController.getInstance(req.nodeId); - const containers = await dockerController.getRunningContainers(); - res.json(containers); + const all = req.query.all === 'true' || req.query.all === '1'; + const containers = all + ? await dockerController.getAllContainers() + : await dockerController.getRunningContainers(); + res.json(await excludeSelfContainers(containers)); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index f11094aa..14eb28fa 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -10,6 +10,7 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD import { NodeRegistry } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; +import { getHostMemory } from '../helpers/hostMemory'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import { StackOpLockService } from '../services/StackOpLockService'; @@ -46,6 +47,8 @@ import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults import { MAX_ASSIGNMENTS } from '../helpers/constants'; import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard'; import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService'; +import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService'; +import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest'; import { PROXY_TIER_HEADER } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; @@ -222,11 +225,11 @@ async function getCompareTarget(gatewayVersion: string | null) { async function fetchLocalNodeOverview(node: Node): Promise { try { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id)); - const [allContainers, stacks, currentLoad, mem, fsSize] = await Promise.all([ + const [allContainers, stacks, currentLoad, hostMem, fsSize] = await Promise.all([ DockerController.getInstance(node.id).getAllContainers(), FileSystemService.getInstance(node.id).getStacks(), si.currentLoad(), - si.mem(), + getHostMemory(), si.fsSize(), ]); @@ -255,13 +258,12 @@ async function fetchLocalNodeOverview(node: Node): Promise { systemStats: { cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length }, memory: { - total: mem.total, - // Percentage is keyed off mem.active (the real working set), not mem.used: - // on Linux/BSD/macOS mem.used counts reclaimable page cache and reads ~99% - // on a busy host. mem.available is total - active, so used + free = total. - used: mem.active, - free: mem.available, - usagePercent: ((mem.active / mem.total) * 100).toFixed(1), + total: hostMem.total, + // ZFS ARC aware: reclaimable ARC is added back into available so a + // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. + used: hostMem.used, + free: hostMem.free, + usagePercent: hostMem.usagePercent.toFixed(1), }, disk: mainDisk ? { total: mainDisk.size, @@ -727,6 +729,169 @@ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Res } }); +interface FleetNodeLabelInventoryResult { + nodeId: number; + nodeName: string; + status: 'ok' | 'error'; + inventory: NodeLabelInventory | null; + error: string | null; +} + +function isStringOrNull(v: unknown): boolean { + return typeof v === 'string' || v === null; +} + +function isLabelIndexContainerRef(v: unknown): boolean { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.id === 'string' + && typeof o.name === 'string' + && isStringOrNull(o.stack) + && isStringOrNull(o.service); +} + +function isLabelValue(v: unknown): boolean { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.key === 'string' + && typeof o.value === 'string' + && typeof o.source === 'string' + && VALID_LABEL_SOURCES.has(o.source); +} + +function isContainerLabelRow(v: unknown): boolean { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.id === 'string' + && typeof o.name === 'string' + && typeof o.state === 'string' + && isStringOrNull(o.stack) + && isStringOrNull(o.service) + && Array.isArray(o.labels) + && o.labels.every(isLabelValue); +} + +function isLabelIndexRow(v: unknown): boolean { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.key === 'string' + && typeof o.value === 'string' + && typeof o.source === 'string' + && VALID_LABEL_SOURCES.has(o.source) + && Array.isArray(o.containers) + && o.containers.every(isLabelIndexContainerRef); +} + +/** + * Validate a remote node's label-inventory payload deeply enough that neither the + * aggregation sort nor the Fleet UI ever receives a malformed row. A single bad + * `byLabel` or `containers` element would otherwise crash the whole fleet request (or + * the client) rather than degrading that node into `nodeErrors`. Only wire fields are + * checked; the internal `imageId` is not part of the shape sent over the wire. + */ +function isNodeLabelInventory(v: unknown): v is NodeLabelInventory { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.nodeId === 'number' + && Array.isArray(o.containers) + && o.containers.every(isContainerLabelRow) + && Array.isArray(o.byLabel) + && o.byLabel.every(isLabelIndexRow); +} + +/** + * Fleet-wide Docker label inventory. Auth + node:read (Community). Fans out to + * each node's /api/system/container-labels; unreachable nodes degrade gracefully. + */ +fleetRouter.get('/container-labels', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePermission(req, res, 'node:read')) return; + if (!requireRevealAdmin(req, res)) return; + const options = labelInventoryOptionsFromRequest(req); + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + + const results = await Promise.allSettled( + nodes.map(async (node: Node): Promise => { + if (node.type === 'local') { + const inventory = await buildNodeLabelInventory(node.id, options); + return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null }; + } + + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: formatNoTargetError(node) }; + } + + const revealQs = options.revealSecrets ? '?reveal=1' : ''; + const resp = await fetch( + `${target.apiUrl.replace(/\/$/, '')}/api/system/container-labels${revealQs}`, + { + headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, + signal: AbortSignal.timeout(30000), + }, + ); + if (!resp.ok) { + const errBody = await resp.json().catch(() => null) as { error?: string } | null; + return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: errBody?.error ?? `Remote returned ${resp.status}` }; + } + const inventory = await resp.json().catch(() => null); + if (!isNodeLabelInventory(inventory)) { + return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: 'Remote returned an unexpected label-inventory payload' }; + } + return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null }; + }), + ); + + const perNode: FleetNodeLabelInventoryResult[] = results.map((result, i) => { + if (result.status === 'fulfilled') return result.value; + console.error(`[Fleet] Container labels fetch failed for node ${nodes[i].name}:`, result.reason); + return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', inventory: null, error: getErrorMessage(result.reason, 'Failed to reach node') }; + }); + + const aggregatedByLabel = new Map(); + for (const nodeResult of perNode) { + if (nodeResult.status !== 'ok' || !nodeResult.inventory) continue; + for (const row of nodeResult.inventory.byLabel) { + const key = `${row.key}\0${row.value}\0${row.source}`; + const existing = aggregatedByLabel.get(key); + if (!existing) { + aggregatedByLabel.set(key, { + ...row, + containers: row.containers.map(c => ({ + ...c, + nodeId: nodeResult.nodeId, + nodeName: nodeResult.nodeName, + })), + }); + } else { + existing.containers.push(...row.containers.map(c => ({ + ...c, + nodeId: nodeResult.nodeId, + nodeName: nodeResult.nodeName, + }))); + } + } + } + + const nodeErrors: Record = {}; + for (const n of perNode) { + if (n.status === 'error' && n.error) nodeErrors[n.nodeId] = n.error; + } + + res.json({ + nodes: perNode, + aggregatedByLabel: [...aggregatedByLabel.values()].sort((a, b) => + a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source)), + nodeErrors, + generatedAt: Date.now(), + }); + } catch (error) { + console.error('[Fleet] Container labels error:', error); + res.status(500).json({ error: 'Failed to build fleet container label inventory' }); + } +}); + interface FleetNetworkingSummaryNode { nodeId: number; nodeName: string; diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts index e6077c5b..c71ec0d0 100644 --- a/backend/src/routes/metrics.ts +++ b/backend/src/routes/metrics.ts @@ -9,6 +9,7 @@ import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants'; +import { getHostMemory } from '../helpers/hostMemory'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { isManagedByComposeDir } from '../utils/managed-containers'; @@ -296,9 +297,9 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res async () => { // Remote-node requests are intercepted and proxied upstream before // reaching here; this fetcher only runs for local nodes. - const [currentLoad, mem, fsSize] = await Promise.all([ + const [currentLoad, hostMem, fsSize] = await Promise.all([ si.currentLoad(), - si.mem(), + getHostMemory(), si.fsSize(), ]); @@ -310,13 +311,12 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res cores: currentLoad.cpus.length, }, memory: { - total: mem.total, - // Percentage is keyed off mem.active (the real working set), not mem.used: - // on Linux/BSD/macOS mem.used counts reclaimable page cache and reads ~99% - // on a busy host. mem.available is total - active, so used + free = total. - used: mem.active, - free: mem.available, - usagePercent: ((mem.active / mem.total) * 100).toFixed(1), + total: hostMem.total, + // ZFS ARC aware: reclaimable ARC is added back into available so a + // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. + used: hostMem.used, + free: hostMem.free, + usagePercent: hostMem.usagePercent.toFixed(1), }, disk: mainDisk ? { fs: mainDisk.fs, diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index e7c58c1a..f9fe2dc4 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,22 +1,29 @@ import { Router, type Request, type Response } from 'express'; -import { DatabaseService } from '../services/DatabaseService'; -import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService'; +import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService'; +import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService'; import type { NotificationCategory } from '../services/NotificationService'; import { NodeRegistry } from '../services/NodeRegistry'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requireAdmin, requireNodeProxy } from '../middleware/tierGates'; import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl, cleanStackPatterns, maskWebhookUrl, } from '../helpers/notificationChannels'; +import { + deleteSuppressionRuleFromFleet, + syncSuppressionRuleToFleet, +} from '../helpers/notificationSuppressionSync'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { parseIntParam } from '../utils/parseIntParam'; const VALID_CATEGORIES: ReadonlySet = new Set(ALL_NOTIFICATION_CATEGORIES); +const VALID_SUPPRESSION_CATEGORIES: ReadonlySet = new Set(ALL_SUPPRESSIBLE_CATEGORIES); +const VALID_LEVELS = new Set(['info', 'warning', 'error']); +const VALID_APPLIES_TO = new Set(['bell', 'external', 'both']); function validateNodeId(nodeId: unknown, res: Response): number | null | false { if (nodeId === undefined || nodeId === null) return null; @@ -41,15 +48,138 @@ function validateLabelIds(label_ids: unknown, res: Response): boolean { return true; } -function validateCategories(categories: unknown, res: Response): boolean { +function validateCategories( + categories: unknown, + res: Response, + allowed: ReadonlySet = VALID_CATEGORIES, +): boolean { if (categories === undefined || categories === null) return true; - if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !VALID_CATEGORIES.has(c as NotificationCategory))) { + if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !allowed.has(c as NotificationCategory))) { res.status(400).json({ error: 'categories must be an array of valid category names' }); return false; } return true; } +function validateSuppressionNodeId(nodeId: unknown, res: Response): number | null | false { + if (nodeId === undefined || nodeId === null) return null; + if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { + res.status(400).json({ error: 'node_id must be an integer or null' }); + return false; + } + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + res.status(400).json({ error: 'node_id must reference a registered node or be null' }); + return false; + } + return nodeId; +} + +function validateLevels(levels: unknown, res: Response): boolean { + if (levels === undefined || levels === null) return true; + if (!Array.isArray(levels) || levels.some((l: unknown) => typeof l !== 'string' || !VALID_LEVELS.has(l))) { + res.status(400).json({ error: 'levels must be an array of info, warning, or error' }); + return false; + } + return true; +} + +function validateAppliesTo(applies_to: unknown, res: Response): NotificationSuppressionAppliesTo | false { + if (typeof applies_to !== 'string' || !VALID_APPLIES_TO.has(applies_to as NotificationSuppressionAppliesTo)) { + res.status(400).json({ error: 'applies_to must be bell, external, or both' }); + return false; + } + return applies_to as NotificationSuppressionAppliesTo; +} + +function validateExpiresAt(expires_at: unknown, res: Response): number | null | false | undefined { + if (expires_at === undefined) return undefined; + if (expires_at === null) return null; + if (typeof expires_at !== 'number' || !Number.isFinite(expires_at)) { + res.status(400).json({ error: 'expires_at must be a finite timestamp or null' }); + return false; + } + return expires_at; +} + +function parseSuppressionRuleBody( + req: Request, + res: Response, + isCreate: boolean, +): Omit | null { + const { + name, + node_id: rawNodeId, + stack_patterns, + label_ids, + categories, + levels, + applies_to, + enabled, + expires_at, + } = req.body; + + if (isCreate && (!name || typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name is required' }); + return null; + } + if (name !== undefined && (typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name must be a non-empty string' }); + return null; + } + if (name !== undefined && name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); + return null; + } + + const nodeIdResult = isCreate || 'node_id' in req.body + ? validateSuppressionNodeId(rawNodeId, res) + : undefined; + if (nodeIdResult === false) return null; + + let cleanedPatterns: string[] | undefined; + if (stack_patterns !== undefined) { + if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) { + res.status(400).json({ error: 'stack_patterns must be an array of strings' }); + return null; + } + cleanedPatterns = cleanStackPatterns(stack_patterns); + } else if (isCreate) { + cleanedPatterns = []; + } + + if (!validateLabelIds(label_ids, res)) return null; + if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null; + if (!validateLevels(levels, res)) return null; + + const appliesToResult = isCreate + ? validateAppliesTo(applies_to, res) + : applies_to !== undefined + ? validateAppliesTo(applies_to, res) + : undefined; + if (appliesToResult === false) return null; + + const expiresAtResult = validateExpiresAt(expires_at, res); + if (expiresAtResult === false) return null; + + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return null; + } + + return { + name: (name as string).trim(), + node_id: nodeIdResult ?? null, + stack_patterns: cleanedPatterns ?? [], + label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null, + categories: Array.isArray(categories) && categories.length > 0 ? categories : null, + levels: Array.isArray(levels) && levels.length > 0 ? levels : null, + applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo, + enabled: enabled !== false, + expires_at: expiresAtResult ?? null, + }; +} + export const notificationsRouter = Router(); notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { @@ -293,3 +423,181 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, } }); +export const notificationSuppressionRouter = Router(); + +notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, res: Response): void => { + if (!requireNodeProxy(req, res)) return; + try { + const rule = req.body?.rule as NotificationSuppressionRule | undefined; + if (!rule || typeof rule.id !== 'number' || typeof rule.name !== 'string') { + res.status(400).json({ error: 'rule object with id and name is required' }); + return; + } + if (!VALID_APPLIES_TO.has(rule.applies_to)) { + res.status(400).json({ error: 'Invalid applies_to on rule' }); + return; + } + DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule); + res.json({ success: true }); + } catch (error) { + console.error('Failed to apply suppression rule replica:', error); + res.status(500).json({ error: 'Failed to apply suppression rule replica' }); + } +}); + +notificationSuppressionRouter.delete('/replica/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireNodeProxy(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete suppression rule replica:', error); + res.status(500).json({ error: 'Failed to delete suppression rule replica' }); + } +}); + +notificationSuppressionRouter.get('/', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const rules = DatabaseService.getInstance().getNotificationSuppressionRules(); + res.json(rules); + } catch (error) { + console.error('Failed to fetch notification suppression rules:', error); + res.status(500).json({ error: 'Failed to fetch notification suppression rules' }); + } +}); + +notificationSuppressionRouter.post('/', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const parsed = parseSuppressionRuleBody(req, res, true); + if (!parsed) return; + + const now = Date.now(); + const rule = DatabaseService.getInstance().createNotificationSuppressionRule({ + ...parsed, + created_at: now, + updated_at: now, + }); + syncSuppressionRuleToFleet(rule); + console.log(`[Suppression] Rule "${sanitizeForLog(rule.name)}" created (id=${rule.id})`); + res.status(201).json(rule); + } catch (error) { + console.error('Failed to create notification suppression rule:', error); + res.status(500).json({ error: 'Failed to create notification suppression rule' }); + } +}); + +notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + + const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id); + if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; } + + const { + name, + node_id: rawNodeId, + stack_patterns, + label_ids, + categories, + levels, + applies_to, + enabled, + expires_at, + } = req.body; + + if (name !== undefined && (typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name must be a non-empty string' }); + return; + } + if (name !== undefined && name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); + return; + } + + let validatedNodeId: number | null | undefined; + if ('node_id' in req.body) { + const result = validateSuppressionNodeId(rawNodeId, res); + if (result === false) return; + validatedNodeId = result; + } + + let cleanedPatterns: string[] | undefined; + if (stack_patterns !== undefined) { + if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) { + res.status(400).json({ error: 'stack_patterns must be an array of strings' }); + return; + } + cleanedPatterns = cleanStackPatterns(stack_patterns); + } + + if (!validateLabelIds(label_ids, res)) return; + if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return; + if (!validateLevels(levels, res)) return; + + let validatedAppliesTo: NotificationSuppressionAppliesTo | undefined; + if (applies_to !== undefined) { + const result = validateAppliesTo(applies_to, res); + if (result === false) return; + validatedAppliesTo = result; + } + + let validatedExpiresAt: number | null | undefined; + if ('expires_at' in req.body) { + const result = validateExpiresAt(expires_at, res); + if (result === false) return; + validatedExpiresAt = result; + } + + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + + const updates: Partial> = { updated_at: Date.now() }; + if (name !== undefined) updates.name = name.trim(); + if (validatedNodeId !== undefined) updates.node_id = validatedNodeId; + if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns; + if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null; + if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null; + if ('levels' in req.body) updates.levels = Array.isArray(levels) && levels.length > 0 ? levels : null; + if (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo; + if (enabled !== undefined) updates.enabled = enabled; + if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt; + + const db = DatabaseService.getInstance(); + db.updateNotificationSuppressionRule(id, updates); + const updated = db.getNotificationSuppressionRule(id)!; + syncSuppressionRuleToFleet(updated); + console.log(`[Suppression] Rule ${id} updated`); + res.json(updated); + } catch (error) { + console.error('Failed to update notification suppression rule:', error); + res.status(500).json({ error: 'Failed to update notification suppression rule' }); + } +}); + +notificationSuppressionRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + + const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id); + if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; } + + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + deleteSuppressionRuleFromFleet(existing); + console.log(`[Suppression] Rule ${id} deleted`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete notification suppression rule:', error); + res.status(500).json({ error: 'Failed to delete notification suppression rule' }); + } +}); + diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 0296c5c5..f621e9a3 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -17,7 +17,7 @@ import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; -import { isValidStackName } from '../utils/validation'; +import { isValidStackName, isValidContainerName } from '../utils/validation'; // Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a // broken subscriber socket cannot turn a successful mutation into a 500. @@ -78,12 +78,30 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: return null; } +function validateContainerTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null { + if (targetType !== 'container') return null; + + if (typeof targetId !== 'string' || !targetId.trim() || nodeId === null || nodeId === undefined) { + return 'Container operations require target_id and node_id.'; + } + + if (targetId !== targetId.trim() || !isValidContainerName(targetId)) { + return 'Container target_id must be a valid container name.'; + } + + if (parsePositiveNodeId(nodeId) === null) { + return 'Container operations require a valid node_id.'; + } + + return null; +} + /** * Shared guard for non-stack actions that require a node. Stack actions use * validateStackTarget because they also require target_id. */ function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null { - if (targetType === 'stack') return null; + if (targetType === 'stack' || targetType === 'container') return null; const def = getScheduledActionDefinition(action); if (!def?.requiresNode) return null; @@ -226,7 +244,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { res.status(400).json({ error: 'Name is required' }); return; } if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) { - res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return; + res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, system, or container.' }); return; } if (!(VALID_ACTIONS as readonly string[]).includes(action)) { res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return; @@ -239,6 +257,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } const stackTargetErr = validateStackTarget(target_type, target_id, node_id); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } + const containerTargetErr = validateContainerTarget(target_type, target_id, node_id); + if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; } const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter); if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } @@ -260,7 +280,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const nextRun = (enabled === false) ? null : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); - const normalizedTargetId = target_type === 'stack' ? target_id : null; + const normalizedTargetId = + target_type === 'stack' || target_type === 'container' ? target_id : null; const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null; const id = DatabaseService.getInstance().createScheduledTask({ @@ -330,7 +351,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const finalAction = (action ?? existing.action) as BackendScheduledAction; const finalTargetType = (target_type ?? existing.target_type) as TargetType; - const finalTargetId = finalTargetType === 'stack' + const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container' ? (target_id !== undefined ? target_id : existing.target_id) : null; const finalNodeId = actionRequiresNode(finalAction) @@ -345,6 +366,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } + const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId); + if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; } + const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter); if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } @@ -364,7 +388,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { updates.name = name.trim(); } if (target_type !== undefined) updates.target_type = finalTargetType; - if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null; + if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) { + updates.target_id = finalTargetId || null; + } if (node_id !== undefined || !actionRequiresNode(finalAction)) { updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null; } diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index b853070f..192fa241 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -27,6 +27,8 @@ import { buildStackNetworkFacts } from '../services/network/composeNetworkInspec import { buildStorageInventory } from '../services/storage/inventory'; import { buildEffectiveAnatomy } from '../services/effectiveAnatomy'; import { buildEnvInventory } from '../services/EnvInventoryService'; +import { buildStackLabelInventory } from '../services/LabelInventoryService'; +import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest'; import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types'; import { UpdateGuardService } from '../services/UpdateGuardService'; import { HealthGateService } from '../services/HealthGateService'; @@ -44,6 +46,7 @@ import { sanitizeForLog } from '../utils/safeLog'; import { sendGitSourceError } from '../utils/gitSourceHttp'; import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describePolicyBlock } from '../helpers/policyGate'; import { parseComposePreview, type ComposePreview } from '../helpers/composePreview'; +import { filterContainersByComposeService } from '../helpers/composeServiceMatch'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection'; import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution'; @@ -1321,6 +1324,22 @@ stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response } }); +// Docker/Compose label inventory: declared compose labels vs runtime container +// labels per service. Read-only; auto-proxies to the active node. +stacksRouter.get('/:stackName/label-inventory', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (!requireRevealAdmin(req, res)) return; + try { + res.json(await buildStackLabelInventory(req.nodeId, stackName, labelInventoryOptionsFromRequest(req))); + } catch (error) { + console.error('[Stacks] Failed to build label inventory for %s:', sanitizeForLog(stackName), + sanitizeForLog(inspect(error, { depth: 4 }))); + res.status(500).json({ error: 'Failed to build label inventory' }); + } +}); + // Exposure intent: the user's per-stack (service '') and per-service exposure // classification, stored separately from generated facts so mismatches stay // detectable. Rows are stored independently; precedence (a service row taking @@ -1646,7 +1665,7 @@ async function handleServiceAction( res.status(404).json({ error: 'No containers found for this stack.' }); return; } - const matching = all.filter(c => c.Service === serviceName); + const matching = filterContainersByComposeService(all, serviceName); if (matching.length === 0) { res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` }); return; diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 180fcbb3..137fd816 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -9,6 +9,9 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { withTimeout, TimeoutError } from '../utils/withTimeout'; +import { buildNodeLabelInventory } from '../services/LabelInventoryService'; +import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest'; +import { requirePermission } from '../middleware/permissions'; // `docker system df` (the call backing estimateSystemReclaim) can take 30+ // seconds on Docker Desktop with many volumes; 8s matches the MonitorService @@ -218,6 +221,19 @@ systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => } }); +// Node-wide Docker/Compose label inventory for fleet fan-out and local audit. +systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'node:read')) return; + if (!requireRevealAdmin(req, res)) return; + try { + const inventory = await buildNodeLabelInventory(req.nodeId, labelInventoryOptionsFromRequest(req)); + res.json(inventory); + } catch (error) { + console.error('Failed to build container label inventory:', error); + res.status(500).json({ error: 'Failed to build container label inventory' }); + } +}); + systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => { try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 2d5cda75..bef0744f 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -25,6 +25,7 @@ export const CAPABILITIES = [ 'network-topology', 'notifications', 'notification-routing', + 'notification-suppression', 'host-console', 'container-exec', 'audit-log', @@ -39,6 +40,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'container-label-inventory', 'project-env-files', 'compose-storage', 'cross-node-rbac', diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f238f311..b09d810c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -377,6 +377,7 @@ export interface NotificationHistory { stack_name?: string; container_name?: string; actor_username?: string | null; + suppression_match?: string | null; } export interface FleetSnapshot { @@ -536,7 +537,7 @@ export interface ApiToken { export interface ScheduledTask { id: number; name: string; - target_type: 'stack' | 'fleet' | 'system'; + target_type: 'stack' | 'fleet' | 'system' | 'container'; target_id: string | null; node_id: number | null; action: BackendScheduledAction; @@ -600,6 +601,23 @@ export interface NotificationRoute { updated_at: number; } +export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both'; + +export interface NotificationSuppressionRule { + id: number; + name: string; + node_id: number | null; + stack_patterns: string[]; + label_ids: number[] | null; + categories: string[] | null; + levels: ('info' | 'warning' | 'error')[] | null; + applies_to: NotificationSuppressionAppliesTo; + enabled: boolean; + expires_at: number | null; + created_at: number; + updated_at: number; +} + export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight'; @@ -871,6 +889,7 @@ export class DatabaseService { this.migrateNotificationRoutes(); this.migrateNotificationRoutesNodeId(); this.migrateNotificationRoutesMatchers(); + this.migrateNotificationSuppressionRules(); this.migrateNotificationHistoryContext(); this.migrateScanPolicyFleetColumns(); this.migrateScanPolicyRiskColumns(); @@ -1814,9 +1833,31 @@ export class DatabaseService { this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL'); } + private migrateNotificationSuppressionRules(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS notification_suppression_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + node_id INTEGER NULL, + stack_patterns TEXT NOT NULL, + label_ids TEXT NULL, + categories TEXT NULL, + levels TEXT NULL, + applies_to TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + expires_at INTEGER NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled + ON notification_suppression_rules(enabled, expires_at); + `); + } + private migrateNotificationHistoryContext(): void { this.tryAddColumn('notification_history', 'stack_name', 'TEXT'); this.tryAddColumn('notification_history', 'container_name', 'TEXT'); + this.tryAddColumn('notification_history', 'suppression_match', 'TEXT'); } private migrateStackDossierHashes(): void { @@ -2293,6 +2334,135 @@ export class DatabaseService { return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes; } + // --- Notification Suppression Rules --- + + private parseNotificationSuppressionRule(row: Record): NotificationSuppressionRule { + return { + id: row.id as number, + name: row.name as string, + node_id: row.node_id != null ? (row.node_id as number) : null, + stack_patterns: JSON.parse(row.stack_patterns as string) as string[], + label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null, + categories: row.categories ? JSON.parse(row.categories as string) as string[] : null, + levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null, + applies_to: row.applies_to as NotificationSuppressionAppliesTo, + enabled: row.enabled === 1, + expires_at: row.expires_at != null ? (row.expires_at as number) : null, + created_at: row.created_at as number, + updated_at: row.updated_at as number, + }; + } + + public getNotificationSuppressionRules(): NotificationSuppressionRule[] { + return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC') + .all() + .map((row) => this.parseNotificationSuppressionRule(row as Record)); + } + + public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] { + return this.db.prepare( + 'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC', + ) + .all(now) + .map((row) => this.parseNotificationSuppressionRule(row as Record)); + } + + public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined { + const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record | undefined; + return row ? this.parseNotificationSuppressionRule(row) : undefined; + } + + public createNotificationSuppressionRule( + rule: Omit, + ): NotificationSuppressionRule { + const result = this.db.prepare( + 'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ).run( + rule.name, + rule.node_id ?? null, + JSON.stringify(rule.stack_patterns), + rule.label_ids ? JSON.stringify(rule.label_ids) : null, + rule.categories ? JSON.stringify(rule.categories) : null, + rule.levels ? JSON.stringify(rule.levels) : null, + rule.applies_to, + rule.enabled ? 1 : 0, + rule.expires_at ?? null, + rule.created_at, + rule.updated_at, + ); + return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!; + } + + public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void { + const existing = this.getNotificationSuppressionRule(rule.id); + if (existing) { + this.db.prepare( + `UPDATE notification_suppression_rules SET + name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?, + applies_to = ?, enabled = ?, expires_at = ?, updated_at = ? + WHERE id = ?`, + ).run( + rule.name, + rule.node_id ?? null, + JSON.stringify(rule.stack_patterns), + rule.label_ids ? JSON.stringify(rule.label_ids) : null, + rule.categories ? JSON.stringify(rule.categories) : null, + rule.levels ? JSON.stringify(rule.levels) : null, + rule.applies_to, + rule.enabled ? 1 : 0, + rule.expires_at ?? null, + rule.updated_at, + rule.id, + ); + return; + } + this.db.prepare( + `INSERT INTO notification_suppression_rules + (id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + rule.id, + rule.name, + rule.node_id ?? null, + JSON.stringify(rule.stack_patterns), + rule.label_ids ? JSON.stringify(rule.label_ids) : null, + rule.categories ? JSON.stringify(rule.categories) : null, + rule.levels ? JSON.stringify(rule.levels) : null, + rule.applies_to, + rule.enabled ? 1 : 0, + rule.expires_at ?? null, + rule.created_at, + rule.updated_at, + ); + } + + public updateNotificationSuppressionRule( + id: number, + updates: Partial>, + ): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } + if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); } + if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); } + if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); } + if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); } + if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); } + if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); } + if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } + if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); } + if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); } + + if (fields.length === 0) return; + values.push(id); + this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } + + public deleteNotificationSuppressionRule(id: number): number { + return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes; + } + // --- Global Settings --- public getGlobalSettings(): Readonly> { @@ -2806,6 +2976,7 @@ export class DatabaseService { container_name: row.container_name ?? undefined, category: row.category ?? undefined, actor_username: row.actor_username ?? null, + suppression_match: row.suppression_match ?? null, }; } @@ -2914,6 +3085,13 @@ export class DatabaseService { this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id); } + public updateNotificationSuppressionMatch( + id: number, + snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean }, + ): void { + this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id); + } + public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] { const since = Date.now() - days * 86400 * 1000; return this.db.prepare(` diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index a4c90b40..47c4a7bd 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -8,6 +8,7 @@ import * as yaml from 'yaml'; import { NodeRegistry } from './NodeRegistry'; import { CacheService } from './CacheService'; +import { FileSystemService } from './FileSystemService'; import SelfIdentityService from './SelfIdentityService'; import { isPathWithinBase } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; @@ -222,6 +223,17 @@ export interface CreateNetworkOptions { Attachable?: boolean; } +export interface LabelInventoryRow { + id: string; + name: string; + state: string; + stack: string | null; + service: string | null; + labels: Record; + imageId: string; + inspectFailed: boolean; +} + class DockerController { private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']); /** @@ -863,6 +875,118 @@ class DockerController { return this.validateApiData(containers); } + /** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */ + public async inspectContainerLabelsAndImage( + containerId: string, + ): Promise<{ labels: Record; imageId: string } | null> { + try { + const info = await this.docker.getContainer(containerId).inspect(); + return { labels: info.Config?.Labels ?? {}, imageId: info.Image ?? '' }; + } catch (err) { + console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(containerId), err); + return null; + } + } + + /** Image-level labels for label provenance. Null (logged) when the image cannot be inspected. */ + public async inspectImageLabels(imageId: string): Promise<{ labels: Record } | null> { + if (!imageId) return null; + try { + const info = await this.docker.getImage(imageId).inspect(); + return { labels: info.Config?.Labels ?? {} }; + } catch (err) { + console.error('[DockerController] Image inspect failed for %s:', sanitizeForLog(imageId), err); + return null; + } + } + + /** + * Containers with runtime labels for the label-inventory API. Resolves stack + * membership with the same multi-fallback strategy as bulk status. Captures the + * image ref so label provenance can distinguish image-inherited labels. + */ + public async listContainersForLabelInventory(): Promise { + const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks(); + const listed = await this.getAllContainers() as Array<{ + Id?: string; + Names?: string[]; + State?: string; + Labels?: Record; + ImageID?: string; + }>; + const projectToStack = await DockerController.resolveProjectNameMap(knownStacks); + const absDirToStack = DockerController.buildAbsDirMap(knownStacks); + const resolvedBase = path.resolve(COMPOSE_DIR); + const knownStackSet = new Set(knownStacks); + + const CONCURRENCY = 8; + const results: LabelInventoryRow[] = new Array(listed.length); + + let index = 0; + const worker = async () => { + while (index < listed.length) { + const i = index++; + const c = listed[i]; + const id = c.Id ?? ''; + const name = (c.Names?.[0] ?? '').replace(/^\//, ''); + const state = c.State ?? 'unknown'; + const stack = DockerController.resolveContainerStack( + c.Labels, + projectToStack, + knownStackSet, + absDirToStack, + resolvedBase, + ); + const service = c.Labels?.['com.docker.compose.service'] ?? null; + if (!id) { + results[i] = { id, name, state, stack, service, labels: {}, imageId: c.ImageID ?? '', inspectFailed: true }; + continue; + } + let labels: Record; + let imageId: string; + let inspectFailed = false; + try { + const info = await this.docker.getContainer(id).inspect(); + labels = info.Config?.Labels ?? {}; + imageId = info.Image ?? ''; + } catch (err) { + console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(id), err); + labels = c.Labels ?? {}; + imageId = c.ImageID ?? ''; + inspectFailed = true; + } + results[i] = { id, name, state, stack, service, labels, imageId, inspectFailed }; + } + }; + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, listed.length) }, worker)); + return results; + } + + /** Resolve a container by its durable name (not ephemeral ID). */ + public async findContainerByName(name: string): Promise<{ + id: string; + name: string; + state: string; + image: string; + stackProject: string | null; + } | null> { + const normalized = name.replace(/^\//, ''); + const containers = await this.getAllContainers(); + for (const c of containers) { + const containerName = c.Names?.[0]?.replace(/^\//, ''); + if (containerName === normalized) { + return { + id: c.Id, + name: containerName, + state: c.State ?? 'unknown', + image: c.Image ?? '', + stackProject: c.Labels?.['com.docker.compose.project'] ?? null, + }; + } + } + return null; + } + /** * Builds topology data with 2 Docker API calls instead of N+1. * Fetches all networks + all containers in parallel, then maps @@ -1488,10 +1612,13 @@ class DockerController { // 2. Extract expected container names with legacy prefix support const expectedNames: string[] = []; + const nameToService = new Map(); for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) { - const config = serviceConfig as any; + const config = serviceConfig as { container_name?: string }; + nameToService.set(serviceName, serviceName); if (config.container_name) { expectedNames.push(config.container_name); + nameToService.set(config.container_name, serviceName); } else { // Standard v2 naming expectedNames.push(serviceName); @@ -1516,6 +1643,11 @@ class DockerController { // 5. Map to the frontend interface return fallbackContainers.map(c => { + const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? ''; + const labelService = c.Labels?.['com.docker.compose.service']; + const service = (typeof labelService === 'string' && labelService.length > 0 + ? labelService + : nameToService.get(strippedName)) ?? ''; let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = []; if (c.Ports && Array.isArray(c.Ports)) { Ports = c.Ports @@ -1525,8 +1657,10 @@ class DockerController { return { Id: c.Id, Names: c.Names, + Service: service, State: c.State, Status: c.Status, + Labels: c.Labels, Ports }; }); diff --git a/backend/src/services/LabelInventoryService.ts b/backend/src/services/LabelInventoryService.ts new file mode 100644 index 00000000..7e5b0d84 --- /dev/null +++ b/backend/src/services/LabelInventoryService.ts @@ -0,0 +1,415 @@ +/** + * Per-node and per-stack Docker/Compose label inventory with provenance and + * optional value redaction for secret-like keys. + */ + +import { ComposeService } from './ComposeService'; +import DockerController from './DockerController'; +import { redactLabelValue } from '../helpers/labelValueRedaction'; +import { sanitizeForLog } from '../utils/safeLog'; + +export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown'; + +/** + * Every valid label source, typed as a set of strings so the wire validator can test an + * untrusted `string` without a cast. The literals are `LabelSource` members, so the union + * still documents the valid values. + */ +export const VALID_LABEL_SOURCES: ReadonlySet = new Set([ + 'compose', 'runtime', 'image', 'compose-system', 'unknown', +]); + +export interface LabelValue { + key: string; + value: string; + source: LabelSource; + redacted?: boolean; +} + +export interface ContainerLabelRow { + id: string; + name: string; + stack: string | null; + service: string | null; + state: string; + labels: LabelValue[]; +} + +export interface LabelIndexContainerRef { + id: string; + name: string; + stack: string | null; + service: string | null; + nodeId?: number; + nodeName?: string; +} + +export interface LabelIndexRow { + key: string; + value: string; + redacted?: boolean; + source: LabelSource; + containers: LabelIndexContainerRef[]; +} + +export interface NodeLabelInventory { + nodeId: number; + containers: ContainerLabelRow[]; + byLabel: LabelIndexRow[]; + partial: boolean; + generatedAt: number; +} + +export interface StackLabelReplica { + id: string; + name: string; + state: string; + runtimeLabels: LabelValue[]; + onlyInCompose: string[]; + onlyOnContainer: string[]; + inBoth: string[]; + /** Keys declared in Compose and present at runtime but with a different value. */ + changed: string[]; + /** Runtime labels could not be read for this replica; reconciliation is skipped. */ + inspectFailed?: boolean; +} + +export interface StackServiceLabelRow { + service: string; + declaredLabels: LabelValue[]; + replicas: StackLabelReplica[]; +} + +export interface StackLabelInventory { + stackName: string; + renderable: boolean; + services: StackServiceLabelRow[]; + /** A replica or its image could not be fully inspected; some provenance is unknown. */ + partial: boolean; + generatedAt: number; +} + +export interface LabelInventoryOptions { + revealSecrets?: boolean; +} + +const INSPECT_CONCURRENCY = 8; +const COMPOSE_SYSTEM_PREFIX = 'com.docker.compose.'; + +function str(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return undefined; +} + +/** + * Truthful provenance for a runtime label. Precedence: compose-system prefix, then + * (stack path only) a Compose-declared key with the same value, then an exact image + * label match, then plain runtime. When the image could not be inspected + * (`imageLabels === null`) an otherwise-unattributable label is `unknown`, not `runtime`. + */ +function resolveLabelSource( + key: string, + value: string, + imageLabels: Record | null, + declared?: Record, +): LabelSource { + if (key.startsWith(COMPOSE_SYSTEM_PREFIX)) return 'compose-system'; + if (declared && declared[key] === value) return 'compose'; + if (imageLabels) return imageLabels[key] === value ? 'image' : 'runtime'; + return 'unknown'; +} + +/** + * Inspect each unique, non-empty image id once (deduped, bounded concurrency). Returns a + * map from image id to its label map, or `null` for an image that could not be inspected, + * and whether any inspection failed (so callers can mark the inventory partial). + */ +async function buildImageLabelMap( + docker: DockerController, + imageIds: string[], +): Promise<{ map: Map | null>; partial: boolean }> { + const unique = [...new Set(imageIds.filter(id => id.length > 0))]; + const inspected = await mapWithConcurrency(unique, INSPECT_CONCURRENCY, async (imageId) => { + const result = await docker.inspectImageLabels(imageId); + return { imageId, labels: result ? result.labels : null }; + }); + const map = new Map | null>(); + let partial = false; + for (const { imageId, labels } of inspected) { + map.set(imageId, labels); + if (labels === null) partial = true; + } + return { map, partial }; +} + +function parseLabelsMap(labels: unknown): Record { + if (Array.isArray(labels)) { + const out: Record = {}; + for (const entry of labels) { + const raw = str(entry); + if (!raw) continue; + const eq = raw.indexOf('='); + if (eq === -1) { + out[raw] = ''; + } else { + out[raw.slice(0, eq)] = raw.slice(eq + 1); + } + } + return out; + } + if (labels && typeof labels === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(labels as Record)) { + const val = str(v); + if (val !== undefined) out[k] = val; + } + return out; + } + return {}; +} + +function toLabelValue( + key: string, + value: string, + source: LabelSource, + revealSecrets: boolean, +): LabelValue { + const redacted = redactLabelValue(key, value, revealSecrets); + return { key, value: redacted.value, source, ...(redacted.redacted ? { redacted: true } : {}) }; +} + +function stripContainerName(names: string[] | undefined): string { + const first = names?.[0]; + if (!first) return ''; + return first.replace(/^\//, ''); +} + +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let index = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + +function buildByLabelIndex( + containers: ContainerLabelRow[], + nodeId?: number, + nodeName?: string, +): LabelIndexRow[] { + const map = new Map(); + for (const container of containers) { + for (const label of container.labels) { + const mapKey = `${label.key}\0${label.value}\0${label.source}`; + let row = map.get(mapKey); + if (!row) { + row = { + key: label.key, + value: label.value, + source: label.source, + ...(label.redacted ? { redacted: true } : {}), + containers: [], + }; + map.set(mapKey, row); + } + row.containers.push({ + id: container.id, + name: container.name, + stack: container.stack, + service: container.service, + ...(nodeId !== undefined ? { nodeId } : {}), + ...(nodeName !== undefined ? { nodeName } : {}), + }); + } + } + return [...map.values()].sort((a, b) => + a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source)); +} + +function reconcileKeys( + declared: Record, + runtime: Record, +): { onlyInCompose: string[]; onlyOnContainer: string[]; inBoth: string[]; changed: string[] } { + const runtimeKeys = new Set(Object.keys(runtime)); + const onlyInCompose: string[] = []; + const onlyOnContainer: string[] = []; + const inBoth: string[] = []; + const changed: string[] = []; + for (const k of Object.keys(declared)) { + if (!runtimeKeys.has(k)) onlyInCompose.push(k); + else if (declared[k] === runtime[k]) inBoth.push(k); + else changed.push(k); + } + for (const k of runtimeKeys) { + if (!(k in declared)) onlyOnContainer.push(k); + } + onlyInCompose.sort(); + onlyOnContainer.sort(); + inBoth.sort(); + changed.sort(); + return { onlyInCompose, onlyOnContainer, inBoth, changed }; +} + +/** Node-wide Docker label inventory for fleet and system routes. */ +export async function buildNodeLabelInventory( + nodeId: number, + options: LabelInventoryOptions = {}, +): Promise { + const revealSecrets = options.revealSecrets === true; + const docker = DockerController.getInstance(nodeId); + const rows = await docker.listContainersForLabelInventory(); + const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, rows.map(r => r.imageId)); + + let missingImage = false; + const containers: ContainerLabelRow[] = rows.map((row) => { + const imageLabels = row.imageId ? (imageLabelMap.get(row.imageId) ?? null) : null; + if (!row.imageId) missingImage = true; + return { + id: row.id, + name: row.name, + stack: row.stack, + service: row.service, + state: row.state, + labels: Object.entries(row.labels).map(([key, value]) => + toLabelValue(key, value, resolveLabelSource(key, value, imageLabels), revealSecrets), + ).sort((a, b) => a.key.localeCompare(b.key)), + }; + }); + + return { + nodeId, + containers, + byLabel: buildByLabelIndex(containers), + partial: rows.some(r => r.inspectFailed) || imagePartial || missingImage, + generatedAt: Date.now(), + }; +} + +/** Per-stack declared vs runtime label reconciliation for Stack Anatomy. */ +export async function buildStackLabelInventory( + nodeId: number, + stackName: string, + options: LabelInventoryOptions = {}, +): Promise { + const revealSecrets = options.revealSecrets === true; + const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); + let renderable = false; + // A failed render leaves the declared map empty. Without the declared model we cannot + // tell a Compose-declared label from a runtime one, so non-system runtime labels are + // resolved to `unknown` and reconciliation is skipped rather than shown as false drift. + // `renderable === false` (not `partial`) signals this to the UI; `partial` is reserved + // for inspection failures so the two banners stay distinct. + let renderFailed = false; + const declaredByService = new Map>(); + + if (result.rendered !== null) { + try { + const parsed = JSON.parse(result.rendered) as { services?: Record }; + for (const [serviceName, svc] of Object.entries(parsed.services ?? {})) { + declaredByService.set(serviceName, parseLabelsMap(svc.labels)); + } + renderable = true; + } catch (err) { + console.error('[LabelInventory] Failed to parse rendered compose for stack %s:', sanitizeForLog(stackName), err); + renderFailed = true; + } + } else { + console.error('[LabelInventory] Compose render failed for stack %s (code %s): %s', + sanitizeForLog(stackName), result.code, sanitizeForLog(result.stderr)); + renderFailed = true; + } + + const docker = DockerController.getInstance(nodeId); + const stackContainers = await docker.getContainersByStack(stackName) as Array<{ Id?: string; Names?: string[]; State?: string; Service?: string }>; + const inspected = await mapWithConcurrency(stackContainers, INSPECT_CONCURRENCY, async (c) => { + const id = c.Id ?? ''; + const name = stripContainerName(c.Names); + const state = c.State ?? 'unknown'; + const service = c.Service ?? null; + const result = id ? await docker.inspectContainerLabelsAndImage(id) : null; + return { id, name, state, service, labels: result?.labels ?? {}, imageId: result?.imageId ?? '', inspectFailed: result === null }; + }); + + const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, inspected.map(r => r.imageId)); + let partial = imagePartial; + + const replicasByService = new Map(); + for (const replica of inspected) { + const svc = replica.service ?? '_unknown'; + const list = replicasByService.get(svc) ?? []; + list.push(replica); + replicasByService.set(svc, list); + } + + const serviceNames = new Set([ + ...declaredByService.keys(), + ...replicasByService.keys(), + ]); + serviceNames.delete('_unknown'); + + const services: StackServiceLabelRow[] = [...serviceNames].sort().map((service) => { + const declared = declaredByService.get(service) ?? {}; + const declaredLabels = Object.entries(declared) + .map(([key, value]) => toLabelValue(key, value, 'compose', revealSecrets)) + .sort((a, b) => a.key.localeCompare(b.key)); + + const replicas: StackLabelReplica[] = (replicasByService.get(service) ?? []).map((rep) => { + // A failed inspect has no runtime labels; reconciling against {} would falsely + // report every declared label as Compose-only, so skip reconciliation and flag it. + if (rep.inspectFailed) { + partial = true; + return { + id: rep.id, name: rep.name, state: rep.state, + runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], + inspectFailed: true, + }; + } + const imageLabels = rep.imageId ? (imageLabelMap.get(rep.imageId) ?? null) : null; + if (!rep.imageId) partial = true; + // Without a declared model, only compose-system keys can be attributed with + // confidence; everything else is unknown, and reconciliation is skipped. + const runtimeLabels = Object.entries(rep.labels) + .map(([key, value]) => { + const source: LabelSource = renderFailed && !key.startsWith(COMPOSE_SYSTEM_PREFIX) + ? 'unknown' + : resolveLabelSource(key, value, imageLabels, declared); + return toLabelValue(key, value, source, revealSecrets); + }) + .sort((a, b) => a.key.localeCompare(b.key)); + const runtimeMap = Object.fromEntries(Object.entries(rep.labels)); + const { onlyInCompose, onlyOnContainer, inBoth, changed } = renderFailed + ? { onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [] } + : reconcileKeys(declared, runtimeMap); + return { + id: rep.id, + name: rep.name, + state: rep.state, + runtimeLabels, + onlyInCompose, + onlyOnContainer, + inBoth, + changed, + }; + }); + + return { service, declaredLabels, replicas }; + }); + + return { + stackName, + renderable, + services, + partial, + generatedAt: Date.now(), + }; +} diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index cf9e0b9f..ccbed302 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -9,6 +9,7 @@ import { NotificationService } from './NotificationService'; import { FleetUpdateTrackerService } from './FleetUpdateTrackerService'; import { isValidVersion, getSenchoVersion } from './CapabilityRegistry'; import { getLatestVersionInfo } from '../utils/version-check'; +import { getHostMemory } from '../helpers/hostMemory'; import { isDebugEnabled } from '../utils/debug'; import { withTimeout, TimeoutError } from '../utils/withTimeout'; @@ -287,9 +288,9 @@ export class MonitorService { // 1. Host Limits — fetch CPU, RAM, disk concurrently if (settings['host_alerts_enabled'] !== '0') { try { - const [currentLoad, mem, fsSize] = await Promise.all([ + const [currentLoad, hostMem, fsSize] = await Promise.all([ withTimeout(si.currentLoad(), STATS_TIMEOUT_MS, 'host CPU stats'), - withTimeout(si.mem(), STATS_TIMEOUT_MS, 'host RAM stats'), + withTimeout(getHostMemory(), STATS_TIMEOUT_MS, 'host RAM stats'), withTimeout(si.fsSize(), STATS_TIMEOUT_MS, 'host disk stats'), ]); @@ -302,10 +303,9 @@ export class MonitorService { this.clearHostMetricSuppression('cpu'); } - // Key off mem.active (the real working set), not mem.used: on Linux/BSD/macOS - // mem.used counts reclaimable page cache and reads ~99% on a busy host, which - // would fire spurious host-memory alerts. - const ramUsage = (mem.active / mem.total) * 100; + // ZFS ARC aware: reclaimable ARC is added back into available so a large ARC + // cache does not fire spurious host-memory alerts. See helpers/hostMemory.ts. + const ramUsage = hostMem.usagePercent; const ramLimit = parseFloat(settings['host_ram_limit']); if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { await this.dispatchHostMetricAlert('ram', 'warning', suppressionMs, diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 75bc3b51..8d619ba5 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -6,6 +6,12 @@ import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { sanitizeNotificationMessage } from '../utils/notificationMessage'; import { StackActivityMetricsService } from './StackActivityMetricsService'; +import { + appliesToBell, + appliesToExternal, + matchesNotificationFilters, + ruleNeedsStackLabels, +} from '../helpers/notificationMatchers'; export type NotificationCategory = | 'deploy_success' @@ -44,6 +50,13 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [ 'node_update_available', 'system', ]; +/** Every category that can appear in notification history / the bell panel. */ +export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [ + ...ALL_NOTIFICATION_CATEGORIES, + 'drift_detected', 'drift_resolved', + 'update_started', 'health_gate_passed', 'health_gate_failed', +]; + /** Webhook timeout: 10 seconds per external dispatch call. */ const WEBHOOK_TIMEOUT_MS = 10_000; @@ -185,23 +198,51 @@ export class NotificationService { }); } + const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules(); + const routes = this.dbService.getEnabledNotificationRoutes(); + const needsStackLabels = stackName !== undefined && ( + ruleNeedsStackLabels(suppressionRules) + || routes.some((r) => r.label_ids != null && r.label_ids.length > 0) + ); + const stackLabelIds = needsStackLabels + ? this.dbService.getStackLabelIds(localNodeId, stackName!) + : []; + const matchCtx = { + localNodeId, + stackName, + category, + level, + stackLabelIds, + }; + const matchedSuppression = suppressionRules.filter((r) => matchesNotificationFilters(matchCtx, r)); + const suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to)); + const suppressExternal = matchedSuppression.some((r) => appliesToExternal(r.applies_to)); + + if (isDebugEnabled() && matchedSuppression.length > 0) { + console.log(`[Notify:diag] Suppression matched ${matchedSuppression.length} rule(s); bell=${suppressBell}, external=${suppressExternal}`); + } + + if (matchedSuppression.length > 0 && notification.id != null) { + this.dbService.updateNotificationSuppressionMatch(notification.id, { + rules: matchedSuppression.map((r) => ({ id: r.id, name: r.name })), + bellSuppressed: suppressBell, + externalSuppressed: suppressExternal, + }); + } + // 2. Push to connected browser clients via WebSocket - this.broadcastToSubscribers(notification); + if (!suppressBell) { + this.broadcastToSubscribers(notification); + } + + if (suppressExternal) { + return; + } // 3. Check notification routing rules — always evaluated, matchers compose AND const errors: string[] = []; - const routes = this.dbService.getEnabledNotificationRoutes(); - const needsLabels = stackName !== undefined && routes.some(r => r.label_ids != null && r.label_ids.length > 0); - const stackLabelIds = needsLabels ? this.dbService.getStackLabelIds(localNodeId, stackName!) : []; - - const matched = routes.filter(r => { - if (r.node_id != null && r.node_id !== localNodeId) return false; - if (r.stack_patterns.length > 0 && (stackName === undefined || !r.stack_patterns.includes(stackName))) return false; - if (r.label_ids != null && r.label_ids.length > 0 && !r.label_ids.some(id => stackLabelIds.includes(id))) return false; - if (r.categories != null && r.categories.length > 0 && !r.categories.includes(category)) return false; - return true; - }); + const matched = routes.filter(r => matchesNotificationFilters(matchCtx, r)); if (matched.length > 0) { if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`); await Promise.allSettled( diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 832521a9..8db8ee54 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -21,6 +21,8 @@ import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; import { CloudBackupService } from './CloudBackupService'; import { buildSystemPolicyGateOptions } from '../helpers/policyGate'; +import { filterContainersByComposeService } from '../helpers/composeServiceMatch'; +import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { enforcePolicyPreDeploy } from './PolicyEnforcement'; import { summarizeBlockReasons } from '../utils/policy-risk'; @@ -424,6 +426,9 @@ export class SchedulerService { } private async executeRestart(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerRestart(task); + } if (!task.target_id || task.node_id == null) { throw new Error('Stack restart requires target_id and node_id'); } @@ -439,7 +444,7 @@ export class SchedulerService { let filtered = containers; if (task.target_services) { const serviceNames: string[] = JSON.parse(task.target_services); - filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service)); + filtered = serviceNames.flatMap(svc => filterContainersByComposeService(containers, svc)); if (filtered.length === 0) { throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`); } @@ -507,6 +512,9 @@ export class SchedulerService { } private async executeAutoStop(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerStop(task); + } this.assertStackTarget(task, 'Auto-stop'); if (this.isRemoteNode(task.node_id)) { await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/stop`); @@ -537,6 +545,9 @@ export class SchedulerService { } private async executeAutoStart(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerStart(task); + } this.assertStackTarget(task, 'Auto-start'); // Remote auto-start proxies to the remote's own deploy route, which runs // that node's scan-policy gate against the images it actually holds. The @@ -802,6 +813,126 @@ export class SchedulerService { * `routeSuffix` is the path under `/api/stacks/`; the caller URL-encodes each * segment. */ + private containerNotFoundMessage(containerName: string, nodeId: number): string { + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Container "${containerName}" not found on node "${nodeName}". It may have been renamed or removed.`; + } + + private async resolveContainerId(task: ScheduledTask): Promise<{ id: string; name: string }> { + if (!task.target_id || task.node_id == null) { + throw new Error('Container operations require target_id and node_id'); + } + const name = task.target_id; + if (this.isRemoteNode(task.node_id)) { + const containers = await this.getRemoteContainers(task.node_id); + const match = containers.find( + c => c.Names?.[0]?.replace(/^\//, '') === name, + ); + if (!match) throw new Error(this.containerNotFoundMessage(name, task.node_id)); + return { id: match.Id, name }; + } + const found = await DockerController.getInstance(task.node_id).findContainerByName(name); + if (!found) throw new Error(this.containerNotFoundMessage(name, task.node_id)); + return { id: found.id, name: found.name }; + } + + private async executeContainerRestart(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'restart'); + } else { + await DockerController.getInstance(nodeId).restartContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Restarted container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async executeContainerStop(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'stop'); + } else { + await DockerController.getInstance(nodeId).stopContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Stopped container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async executeContainerStart(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'start'); + } else { + await DockerController.getInstance(nodeId).startContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Started container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async getRemoteContainers(nodeId: number): Promise> { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!proxyTarget) { + throw new Error('Remote node is not configured or missing API credentials'); + } + const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const response = await fetch(`${baseUrl}/api/containers?all=true`, { + headers: { + 'Authorization': `Bearer ${proxyTarget.apiToken}`, + [PROXY_TIER_HEADER]: proxyHeaders.tier, + }, + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`); + } + return excludeSelfContainers(await response.json() as Array<{ + Id: string; + Names?: string[]; + State?: string; + Image?: string; + ImageID?: string; + }>); + } + + private async postToRemoteContainer( + nodeId: number, + containerId: string, + action: 'start' | 'stop' | 'restart', + ): Promise { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!proxyTarget) { + throw new Error('Remote node is not configured or missing API credentials'); + } + const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const response = await fetch( + `${baseUrl}/api/containers/${encodeURIComponent(containerId)}/${action}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${proxyTarget.apiToken}`, + [PROXY_TIER_HEADER]: proxyHeaders.tier, + }, + signal: AbortSignal.timeout(300_000), + }, + ); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`); + } + } + private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise { const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); if (!proxyTarget) { diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts index 417a9959..295c94a2 100644 --- a/backend/src/services/scheduledActionRegistry.ts +++ b/backend/src/services/scheduledActionRegistry.ts @@ -10,7 +10,7 @@ * each side. */ -export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; +export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const; export type TargetType = typeof VALID_TARGET_TYPES[number]; export interface BackendScheduledActionDefinition { @@ -26,15 +26,15 @@ export interface BackendScheduledActionDefinition { * in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ..."). */ export const BACKEND_SCHEDULED_ACTIONS = [ - { id: 'restart', targetTypes: ['stack'], requiresNode: true }, + { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true }, { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false }, { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true }, { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, { id: 'auto_backup', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_stop', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true }, { id: 'auto_down', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_start', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true }, ] as const satisfies readonly BackendScheduledActionDefinition[]; export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id']; @@ -58,15 +58,15 @@ const ACTION_BY_ID = new Map = { - restart: 'Restart action requires target_type "stack".', + restart: 'Restart action requires target_type "stack" or "container".', snapshot: 'Snapshot action requires target_type "fleet".', prune: 'Prune action requires target_type "system".', update: 'Update action requires target_type "stack" or "fleet".', scan: 'Scan action requires target_type "system".', auto_backup: 'auto_backup action requires target_type "stack".', - auto_stop: 'auto_stop action requires target_type "stack".', + auto_stop: 'auto_stop action requires target_type "stack" or "container".', auto_down: 'auto_down action requires target_type "stack".', - auto_start: 'auto_start action requires target_type "stack".', + auto_start: 'auto_start action requires target_type "stack" or "container".', }; /** diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index cc372a03..97015ba0 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'PUT /notification-routes': 'Updated notification route', 'DELETE /notification-routes': 'Deleted notification route', 'POST /notification-routes/*/test': 'Tested notification route', + 'POST /notification-suppression-rules': 'Created notification suppression rule', + 'PUT /notification-suppression-rules': 'Updated notification suppression rule', + 'DELETE /notification-suppression-rules': 'Deleted notification suppression rule', // Webhooks 'POST /webhooks': 'Created webhook', diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 1a450cd1..fce3cf17 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -8,6 +8,10 @@ import { sanitizeForLog } from './safeLog'; export const isValidStackName = (name: string): boolean => /^[a-zA-Z0-9_-]+$/.test(name); +/** Docker container name (no path separators). Used for scheduled container targets. */ +export const isValidContainerName = (name: string): boolean => + !name.includes('..') && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/.test(name); + /** * Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL. * Rejects loopback addresses to prevent SSRF against local services. diff --git a/docker-compose.yml b/docker-compose.yml index cc27edd7..a07d3e26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,15 +29,27 @@ services: # (Optional but Recommended) Media/Data Drives # Mount your media drives here so Docker Compose inside Sencho can validate paths during deployment. - /path/to/your/media/drives:/path/to/your/media/drives - + + # (Optional, ZFS hosts only) OpenZFS ARC stats for ZFS-aware host memory. + # ARC cache is reclaimable but the kernel reports it as used, which can + # trigger false host-memory alerts. Uncomment to let Sencho treat ARC as + # available memory. Usually already visible in the container; only needed + # if your runtime does not expose /proc/spl/kstat/zfs/arcstats. + # - /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro + environment: # ENVIRONMENT VARIABLES FOR INSIDE THE CONTAINER - + # This points to the Container Path (right side) of your 1:1 mount above - COMPOSE_DIR=/path/to/your/docker/folder/compose - + # This points to the Container Path (right side) of your database mount above. Leave this as /app/data. - DATA_DIR=/app/data + + # (Optional, ZFS hosts only) Container-side path to the OpenZFS ARC stats + # file, if it is not at a standard location. Leave empty to auto-detect + # /host/proc/spl/kstat/zfs/arcstats then /proc/spl/kstat/zfs/arcstats. + - SENCHO_ZFS_ARCSTATS_PATH=${SENCHO_ZFS_ARCSTATS_PATH:-} # ⚠️ GLOBAL ENVIRONMENT VARIABLES ⚠️ # If your compose files rely on host-level shell variables (like $PUID, $TZ) diff --git a/docs/docs.json b/docs/docs.json index 5b66799c..b1ef8d73 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -108,6 +108,7 @@ "features/compose-doctor", "features/compose-networking", "features/environment-guardrails", + "features/docker-label-audit", "features/compose-storage", "features/stack-labels", "features/sidebar" diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 6b1a62c1..1f4f72eb 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -101,6 +101,36 @@ Three icon actions appear on the right edge of each card: The masthead carries `SCOPE` (`global`), `ROUTES` (total), and `ENABLED` (count). +## Mute Rules + + + Admin role is required to create, edit, or delete mute rules. + + +Notification suppression rules **hide or drop** matching alerts. They do not send alerts elsewhere. Routing and suppression are separate: a rule can match the same alert as a route, but suppression is evaluated first and can block bell delivery, external channels, or both. + +Open **Settings · Notifications · Mute Rules** and click **+ Add mute rule**. + +| Field | Purpose | +|-------|---------| +| **Name** | A human label, up to 100 characters. | +| **Node scope** | `Any node` or a specific fleet node. Limits which node emits the alert before the rule can match. | +| **Stacks** *(optional)* | Stack names. Empty matches any stack. | +| **Labels** *(optional)* | Stack labels. Empty matches any label. | +| **Categories** *(optional)* | Notification categories. Empty matches any category. | +| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. | +| **Apply to** | **Bell**, **External**, or **Both**. Bell skips the notification popover WebSocket push. External skips routing rules and global channels. | +| **Expiration** | Forever, 1 hour, 24 hours, or a custom date. Expired rules stop matching automatically. | +| **Enabled** | Toggle the rule without deleting it. | + +All non-empty matchers must match (AND). Suppressed alerts are still written to stack activity history; only delivery is affected. When a mute rule blocks delivery, the activity row shows a **Suppressed** badge with the matched rule name in a tooltip. + +Rules you create on the control instance replicate to remote nodes so alerts emitted on a remote stack honor the same suppression. From the bell, admins can open a row menu and choose **Mute this category**, **Mute notifications like this**, or **Mute this stack** to create a quick rule with default **Both** targeting. The same presets are available from stack menus (sidebar, stack header, activity tab), fleet node cards, and label groups. + +### Built-in bell quieting (not a mute rule) + +Sencho also hides one class of notification from the popover without a user rule: rows where the category is one of `deploy_success`, `stack_started`, `stack_stopped`, `stack_restarted`, or `image_update_applied`, and the row carries an `actor_username` other than `system`. These are confirmations of an action you just clicked. The rows are still persisted and still dispatched externally; only the bell render hides them. Use mute rules when you need configurable, operator-controlled muting. + ## Notification categories Every alert Sencho dispatches carries a category that you can filter on in the bell, target with a routing rule's **Categories** matcher, or reason about when reading audit history. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index f7a842e2..0baa7984 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -47,6 +47,10 @@ The gauge bars (and the corresponding numeric values) pick up amber at 80% and r While the dashboard is loading the CPU tile reads `--` and the caption shows `collecting metrics…`; bars and sparklines render once the first sample arrives. + + **ZFS hosts:** the memory tile and host RAM alerts are ZFS ARC-aware. Reclaimable ARC cache is treated as available memory rather than used, so a large ARC does not inflate the gauge or trigger false low-memory alerts. See [ZFS ARC-aware host memory](/getting-started/configuration#zfs-arc-aware-host-memory) for how to expose ARC stats to a Docker install. + + ## Stack health A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorted so the stacks demanding attention sit at the top. diff --git a/docs/features/docker-label-audit.mdx b/docs/features/docker-label-audit.mdx new file mode 100644 index 00000000..d37f456a --- /dev/null +++ b/docs/features/docker-label-audit.mdx @@ -0,0 +1,55 @@ +--- +title: "Docker Label Audit" +description: "Audit Docker and Compose labels that drive external automation across the fleet and inside each stack." +--- + +Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement. + +Sencho surfaces two read-only audit views for these labels: + +- **Fleet · Docker Labels** for estate-wide visibility across every node +- **Stack · Compose Labels** for per-stack reconciliation between declared Compose labels and labels present on running containers + +## Fleet Docker Labels tab + +Open **Fleet** and select the **Docker Labels** tab (after **Map**). The panel is titled **Docker label audit** and offers two layouts: + +| View | What it shows | +|------|----------------| +| **By container** | Each container with its label count. Expand a row to see every key, value, and provenance badge. | +| **By label** | Each unique `key=value` pair and every container that carries it, with node name chips on multi-node fleets. | + +Use the search box to filter by label key, value, container name, stack, or node. When a container belongs to a known stack, **Open stack** jumps to that stack in the editor. + +Every label carries a provenance badge so you can tell where it came from: + +- **Image** for labels inherited from the container image (for example OCI `org.opencontainers.image.*` metadata) +- **Present at runtime** for other labels set on the running container +- **Docker Compose system label** for keys starting with `com.docker.compose.` +- **Unknown** when a container or its image could not be inspected + +The fleet view reads container-level metadata and does not open the compose file, so it cannot tell a Compose-declared label from any other label set on the container: labels you declared in Compose appear here as **Present at runtime**. To see which labels come from the compose file, use the per-stack **Compose Labels** tab below. When a container or its image cannot be inspected, the affected labels show as **Unknown** and the panel names the nodes it could not fully inspect. + +In the **By label** layout, open the **Filters** popover next to the search box. The **Defined by** section lists toggle pills for each provenance type present in the data (**Image**, **Runtime**, **System**, and **Unknown** when present). Turn a pill off to hide that source; the button shows a count badge while any filter is active, and **Clear filters** resets the popover. Automation tools such as Watchtower, Diun, and Traefik read these labels, so the audit makes it easy to confirm which containers are opted in or out. + +Runtime labels are static until the container is recreated. Changes declared in Compose require save and redeploy before they appear on running containers. + +## Stack Compose Labels tab + +Inside the stack editor, open the **Compose Labels** tab in the anatomy strip. For each service you see: + +- **Declared in Compose** labels from the effective rendered compose model +- **Present at runtime** labels read from each running replica, each with its provenance badge (Compose, Image, or runtime) +- Reconciliation hints: **only in Compose**, **only on running container**, **present in both**, or **value changed** when a key is declared and running but the values differ + +Because this tab renders the compose model, it can identify Compose-declared labels accurately. A toolbar at the top combines a **search box** with a **Filters** popover. The search matches label keys and values as well as service and container names. The popover has a **Defined by** section with toggle pills for each provenance present (**Compose File**, **Image**, **Runtime**, **System**) and a **Services** section to show or hide individual service cards when the stack has more than one service. The Filters button shows a count badge for active facet and service filters; **Clear filters** resets both sections. Matching a service or container name reveals that parent's labels even when the text does not match a specific key or value. Reconciliation counts always reflect only the labels currently visible. + +When Compose cannot be fully rendered, the panel warns that declared labels may be incomplete but still shows whatever runtime data is available. If a replica cannot be inspected, it is flagged with **Runtime labels unavailable** and the panel notes that provenance may be incomplete. + +## Sensitive values + +Some label keys look like secrets (for example keys containing `token`, `password`, or `auth`). Their values are redacted by default. Admins can reveal full values with the **Reveal** control, which re-fetches the inventory with elevated read access. + +## Editing labels + +Compose label editing from these panels is not available yet. To change labels today, edit the compose file directly and redeploy the stack. diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 8ed0ce74..431a8090 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -80,6 +80,7 @@ The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and | **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). | | **Drift** | Yes | Live comparison of the declared compose against the running containers. See [Stack Drift](/features/stack-drift). | | **Environment** | When `env-inventory` capability is present | Variable inventory across all env files, with status for each variable. See [Environment Guardrails](/features/environment-guardrails). | +| **Compose Labels** | When `container-label-inventory` capability is present | Declared Compose labels vs runtime container labels per service. See [Docker Label Audit](/features/docker-label-audit). | | **Networking** | When `compose-networking` capability is present | Port exposure summary per service with intent classification. See [Compose Networking](/features/compose-networking). | | **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). | | **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). | diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 72db0b8c..95e0788a 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -26,8 +26,8 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute. - **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown. -- **Five lanes.** Stack lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Stack lifecycle lane holds the five stack-lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots. -- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task. +- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down) and standalone container actions (Restart Container, Stop Container, Start Container); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots. +- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the container name for container actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task. - **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically). - **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window. @@ -47,7 +47,7 @@ The All tasks toggle swaps the lane track for a sortable table. |---|---| | **Name** | The task name. | | **Action** | A badge labelling the operation (e.g. Restart Stack, Scan Node Images, Create Fleet Snapshot). | -| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), or the target type for non-stack actions (`system`, `fleet`). | +| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), the container name for container actions, or the target type for other non-stack actions (`system`, `fleet`). | | **Schedule** | A human-readable description of the cron with the raw expression on a second line. | | **Status** | The last run result: **Success** (green), **Failed** (red), or `Never run` if the task has not fired yet. | | **Next Run** | The timestamp of the next firing, or a dash if the task is disabled or has no upcoming runs. | @@ -68,10 +68,13 @@ The All tasks toggle swaps the lane track for a sortable table. | **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. | | **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. | | **Start / Bring Up Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works for both stopped and removed containers: if they exist they are started, if not they are created from the compose file. | +| **Restart Container** | A specific container by name on a specific node | Restarts one container directly through Docker. Use for third-party or standalone containers that are not managed as a Sencho stack. | +| **Stop Container** | A specific container by name on a specific node | Stops one container. The container remains on disk for a faster start later. | +| **Start Container** | A specific container by name on a specific node | Starts one stopped container by name. | ## Creating a scheduled task -Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Stack lifecycle, Updates, Security, Maintenance, and Backups. +Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Lifecycle, Updates, Security, Maintenance, and Backups. The New scheduled task modal with the Action combobox expanded. The dropdown groups actions under category headers: Stack lifecycle (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down), Updates (Auto-update Stack, Auto-update All Stacks on Node), Security (Scan Node Images), Maintenance (Prune Node Resources), and Backups (Create Fleet Snapshot). Below the picker, partly visible, sit a Services row with 'echo' and 'prober' checkboxes, the Cron Expression input, the Enabled toggle, and the Delete after successful run checkbox. @@ -88,6 +91,7 @@ Common fields: Conditional fields per action: - **Stack actions** (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Auto-update Stack, Stop Stack, Take Stack Down) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack. +- **Container actions** (Restart Container, Stop Container, Start Container) add a **Node** combobox and a **Container** combobox listing every container on that node (running and stopped). The picker shows each container's name, state, and image. When the container is not part of a Sencho stack, helper text explains that the schedule targets the container by node and name. - **Auto-update All Stacks on Node** adds a **Node** combobox. The helper text "Checks every stack on the selected node and updates stacks with newer images" appears above, next to the Runtime change badge. - **Scan Node Images** adds a **Node** combobox listing local nodes only. The helper text "Runs Trivy against images on the selected local node and records the findings" and Read-only badge appear above. - **Prune Node Resources** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label. diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 6dba1e8c..0fc30e50 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -43,9 +43,25 @@ These tune optional subsystems. Most deployments never set them; the defaults ar | `GITSOURCE_MAX_CLONE_BYTES` | `104857600` | Maximum bytes a single [Git Source](/features/git-sources) clone may download before it is aborted (100 MB). A shallow Compose clone is tiny; raise it only if you track Compose files in a legitimately large repository. | | `SENCHO_PUBLIC_URL` | *(request host)* | Set on the primary instance. Its externally reachable `http(s)://` URL, no trailing slash, baked into pilot enrollment so remote agents dial the public hostname rather than the address the admin used at setup. | | `SENCHO_COMPOSE_STALL_TIMEOUT_MS` | `600000` | Idle-output backstop for deploy and update Compose steps (pull and recreate). If a step produces no output for this long while still running, Sencho stops it so a hung image pull surfaces a clear failure and the in-app recovery actions instead of spinning. Raise it on slow links or for heavy local image builds. | +| `SENCHO_ZFS_ARCSTATS_PATH` | *(auto)* | Path **inside the container** to the OpenZFS ARC kstat file, for [ZFS ARC-aware host memory](#zfs-arc-aware-host-memory). Sencho checks this path first, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set it only when your ARC stats live at a non-standard path. | Running a remote host as a pilot agent uses four more variables (`SENCHO_MODE`, `SENCHO_PRIMARY_URL`, `SENCHO_ENROLL_TOKEN`, and `SENCHO_PILOT_CA_FILE`), set only on the remote agent container. Sencho bakes them into the enrollment Compose file it generates, so you rarely write them by hand. See [Pilot Agent](/features/pilot-agent) for the full enrollment walkthrough. +## ZFS ARC-aware host memory + +On OpenZFS hosts (TrueNAS SCALE, Proxmox, ZFS on Ubuntu or Debian) the ZFS ARC cache can hold a large share of RAM. ARC is reclaimable on demand, but the Linux kernel reports it as unavailable, so a naive reading counts ARC as used memory and can raise false host-memory alerts. + +Sencho reads the ARC kstat when it is available and adds the reclaimable portion back into available memory, so the dashboard memory gauge and host RAM alerts reflect real memory pressure. When no ARC stats are readable the behavior is unchanged. + +The ARC kstat is usually visible inside the container at `/proc/spl/kstat/zfs/arcstats` with no extra configuration. If your runtime does not expose it, mount it read-only: + +```yaml +volumes: + - /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro +``` + +Sencho checks `SENCHO_ZFS_ARCSTATS_PATH`, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set `SENCHO_ZFS_ARCSTATS_PATH` only if your ARC stats live somewhere else inside the container. + ## Listen port Sencho always listens on `1852` inside the container. The port is fixed and is not read from an environment variable. To expose Sencho on a different host port, remap with Docker's `-p` flag (or the `ports:` key in your compose file): diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a704df31..eb731ed9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -149,6 +149,148 @@ components: type: boolean example: true + LabelSource: + type: string + description: Provenance of a label. `unknown` when a container or image could not be inspected. + enum: [compose, runtime, image, compose-system, unknown] + + LabelValue: + type: object + required: [key, value, source] + properties: + key: { type: string } + value: + type: string + description: Redacted to `[redacted]` for secret-like keys unless the caller is an admin and passes `reveal=1`. + source: { $ref: "#/components/schemas/LabelSource" } + redacted: { type: boolean } + + LabelIndexContainerRef: + type: object + required: [id, name, stack, service] + properties: + id: { type: string } + name: { type: string } + stack: { type: string, nullable: true } + service: { type: string, nullable: true } + nodeId: { type: integer } + nodeName: { type: string } + + LabelIndexRow: + type: object + description: One unique key/value/source and every container carrying it. + required: [key, value, source, containers] + properties: + key: { type: string } + value: { type: string } + source: { $ref: "#/components/schemas/LabelSource" } + redacted: { type: boolean } + containers: + type: array + items: { $ref: "#/components/schemas/LabelIndexContainerRef" } + + ContainerLabelRow: + type: object + required: [id, name, stack, service, state, labels] + properties: + id: { type: string } + name: { type: string } + stack: { type: string, nullable: true } + service: { type: string, nullable: true } + state: { type: string } + labels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + + StackLabelReplica: + type: object + required: [id, name, state, runtimeLabels, onlyInCompose, onlyOnContainer, inBoth, changed] + properties: + id: { type: string } + name: { type: string } + state: { type: string } + runtimeLabels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + onlyInCompose: { type: array, items: { type: string } } + onlyOnContainer: { type: array, items: { type: string } } + inBoth: { type: array, items: { type: string } } + changed: + type: array + items: { type: string } + description: Keys declared in Compose and present at runtime but with a different value. + inspectFailed: + type: boolean + description: Runtime labels could not be read for this replica; reconciliation was skipped. + + StackServiceLabelRow: + type: object + required: [service, declaredLabels, replicas] + properties: + service: { type: string } + declaredLabels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + replicas: + type: array + items: { $ref: "#/components/schemas/StackLabelReplica" } + + StackLabelInventory: + type: object + required: [stackName, renderable, services, partial, generatedAt] + properties: + stackName: { type: string } + renderable: + type: boolean + description: False when the Compose model could not be rendered; declared provenance is then unknown. + partial: + type: boolean + description: A replica or its image could not be fully inspected. + generatedAt: { type: integer } + services: + type: array + items: { $ref: "#/components/schemas/StackServiceLabelRow" } + + NodeLabelInventory: + type: object + required: [nodeId, containers, byLabel, partial, generatedAt] + properties: + nodeId: { type: integer } + partial: { type: boolean } + generatedAt: { type: integer } + containers: + type: array + items: { $ref: "#/components/schemas/ContainerLabelRow" } + byLabel: + type: array + items: { $ref: "#/components/schemas/LabelIndexRow" } + + FleetLabelInventory: + type: object + required: [nodes, aggregatedByLabel, nodeErrors, generatedAt] + properties: + generatedAt: { type: integer } + nodes: + type: array + items: + type: object + required: [nodeId, nodeName, status, inventory, error] + properties: + nodeId: { type: integer } + nodeName: { type: string } + status: { type: string, enum: [ok, error] } + inventory: + nullable: true + allOf: [{ $ref: "#/components/schemas/NodeLabelInventory" }] + error: { type: string, nullable: true } + aggregatedByLabel: + type: array + items: { $ref: "#/components/schemas/LabelIndexRow" } + nodeErrors: + type: object + additionalProperties: { type: string } + description: Map of node id to error for nodes that were unreachable or returned a malformed payload. + FailureClassification: type: object description: Classified cause of a failed deploy or update, with a suggested next step. @@ -386,13 +528,13 @@ components: type: string target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: ["string", "null"] - description: Stack name (when target_type is `stack`). + description: Stack or container name (when target_type is `stack` or `container`). node_id: type: ["integer", "null"] - description: Target node ID (when target_type is `stack`). + description: Target node ID (when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] @@ -947,6 +1089,107 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/label-inventory: + get: + operationId: getStackLabelInventory + tags: [Stacks] + summary: Get Docker label inventory for a stack + description: >- + Returns declared Compose labels and runtime container labels per service, + with reconciliation hints (only in Compose, only on running container, + present in both, or value changed when the values differ) and provenance + for each runtime label (compose, image, runtime, compose-system, or unknown + when a container or image cannot be inspected). Secret-like label values are + redacted unless the caller is an admin and passes `reveal=1`. Requires + `stack:read` permission. + parameters: + - $ref: "#/components/parameters/stackName" + - $ref: "#/components/parameters/nodeId" + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Stack label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/StackLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Stack not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/InternalError" + + /api/system/container-labels: + get: + operationId: getNodeContainerLabels + tags: [Fleet] + summary: Get the Docker label inventory for the active node + description: >- + Returns every container on the node with its labels and provenance + (compose, image, runtime, compose-system, or unknown), plus an inverted + `byLabel` index. Marks `partial` when a container or image inspect fails. + Secret-like values are redacted unless the caller is an admin and passes + `reveal=1`. Requires `node:read` permission. + parameters: + - $ref: "#/components/parameters/nodeId" + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Node container label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" + + /api/fleet/container-labels: + get: + operationId: getFleetContainerLabels + tags: [Fleet] + summary: Get the Docker label inventory aggregated across the fleet + description: >- + Fans out to every node's container label inventory and aggregates the + inverted index by key, value, and source. Unreachable or malformed nodes + degrade into `nodeErrors` rather than failing the whole request. Secret-like + values are redacted unless the caller is an admin and passes `reveal=1`. + Requires `node:read` permission. + parameters: + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Fleet-wide container label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/FleetLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/env: get: operationId: getStackEnv @@ -2717,7 +2960,8 @@ paths: summary: Create scheduled task description: | Creates a new recurring task. Action-target rules: - - `restart` requires `target_type: stack` (with `target_id` and `node_id`) + - `restart` requires `target_type: stack` or `target_type: container` (with `target_id` and `node_id`) + - `auto_stop` and `auto_start` accept `target_type: stack` or `target_type: container` - `snapshot` requires `target_type: fleet` - `prune` requires `target_type: system` @@ -2735,13 +2979,13 @@ paths: example: Nightly restart target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: string - description: Stack name (required when target_type is `stack`). + description: Stack or container name (required when target_type is `stack` or `container`). node_id: type: integer - description: Target node ID (required when target_type is `stack`). + description: Target node ID (required when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] @@ -2828,9 +3072,10 @@ paths: type: string target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: string + description: Stack or container name when target_type is `stack` or `container`. node_id: type: integer action: diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 5a4d3e52..362a15ac 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -419,6 +419,32 @@ See [Notification Routing](/features/alerts-notifications#notification-routing) --- +## Mute Rules + + + Creating, editing, and deleting mute rules is admin-only. + + +**Scope:** Global, admin-only + +Create notification suppression rules that mute or drop matching alerts from the bell, external channels, or both. Suppression is evaluated before routing. The masthead publishes **RULES** and **ACTIVE** counts. + +| Field | Description | +|-------|-------------| +| **Name** | Operator-facing label for the rule. | +| **Node** | Specific node, or all nodes if left empty. | +| **Stack patterns** | Stack names to match (multi-select). | +| **Labels** | Match stacks that carry any selected label. | +| **Categories** | Notification categories to suppress. | +| **Severity** | Info, warning, and/or error levels to suppress. | +| **Apply to** | Bell only, external channels only, or both. | +| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. | +| **Enabled** toggle | Disable a rule without deleting it. | + +See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, compose-first shortcuts, and bell quick-mute. + +--- + ## Image update checks **Scope:** Per-node (applies to the currently selected node) diff --git a/frontend/index.html b/frontend/index.html index 3d1bb97e..74312a9b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,9 +3,9 @@ - - - + + + Sencho diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 399a1d0d..a3f0f73a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,21 +11,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.17", - "@radix-ui/react-checkbox": "^1.3.5", - "@radix-ui/react-context-menu": "^2.3.1", - "@radix-ui/react-dialog": "^1.1.17", - "@radix-ui/react-dropdown-menu": "^2.1.18", - "@radix-ui/react-hover-card": "^1.1.17", - "@radix-ui/react-label": "^2.1.10", - "@radix-ui/react-popover": "^1.1.17", - "@radix-ui/react-scroll-area": "^1.2.12", - "@radix-ui/react-select": "^2.3.1", - "@radix-ui/react-separator": "^1.1.10", - "@radix-ui/react-slider": "^1.4.1", + "@radix-ui/react-alert-dialog": "^1.1.18", + "@radix-ui/react-checkbox": "^1.3.6", + "@radix-ui/react-context-menu": "^2.3.2", + "@radix-ui/react-dialog": "^1.1.18", + "@radix-ui/react-dropdown-menu": "^2.1.19", + "@radix-ui/react-hover-card": "^1.1.18", + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-popover": "^1.1.18", + "@radix-ui/react-scroll-area": "^1.2.13", + "@radix-ui/react-select": "^2.3.2", + "@radix-ui/react-separator": "^1.1.11", + "@radix-ui/react-slider": "^1.4.2", "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.15", - "@radix-ui/react-tooltip": "^1.2.10", + "@radix-ui/react-tabs": "^1.1.16", + "@radix-ui/react-tooltip": "^1.2.11", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -34,22 +34,22 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "cronstrue": "^3.21.0", + "cronstrue": "^3.24.0", "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.21.0", + "lucide-react": "^1.23.0", "monaco-editor": "^0.55.1", - "motion": "^12.41.0", + "motion": "^12.42.2", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.0", + "radix-ui": "^1.6.1", "react": "^19.2.7", "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.9.0", + "recharts": "^3.9.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", @@ -57,16 +57,16 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@tailwindcss/vite": "^4.3.1", + "@tailwindcss/vite": "^4.3.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.5.0", + "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", @@ -74,8 +74,8 @@ "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0", - "vite": "^8.1.0", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.2", "vitest": "^4.1.9" }, "engines": { @@ -141,13 +141,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -156,9 +156,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -166,21 +166,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -197,14 +197,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -214,14 +214,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -231,9 +231,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -241,29 +241,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -273,9 +273,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -283,9 +283,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -293,9 +293,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -303,27 +303,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -343,33 +343,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -377,14 +377,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -949,12 +949,12 @@ "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.10.tgz", - "integrity": "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz", + "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.6" + "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", @@ -972,19 +972,19 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", - "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.15.tgz", + "integrity": "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.14", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collapsible": "1.1.15", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -1003,16 +1003,16 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz", - "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.18.tgz", + "integrity": "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dialog": "1.1.17", - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-dialog": "1.1.18", + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1030,12 +1030,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", - "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", + "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1053,12 +1053,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.10.tgz", - "integrity": "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz", + "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1076,13 +1076,13 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz", - "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.1.tgz", + "integrity": "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g==", "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" @@ -1103,16 +1103,16 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz", - "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz", + "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" @@ -1133,9 +1133,9 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", - "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.15.tgz", + "integrity": "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -1143,7 +1143,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -1163,14 +1163,14 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", - "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", + "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { @@ -1219,15 +1219,15 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.1.tgz", - "integrity": "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.2.tgz", + "integrity": "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-menu": "2.1.18", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -1246,21 +1246,21 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", - "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", + "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", @@ -1297,16 +1297,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", - "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", + "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" + "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", @@ -1324,17 +1324,17 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", - "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz", + "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.18", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -1368,13 +1368,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", - "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", + "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { @@ -1393,17 +1393,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.10.tgz", - "integrity": "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.11.tgz", + "integrity": "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.10", - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1421,19 +1421,19 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.17.tgz", - "integrity": "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.18.tgz", + "integrity": "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -1470,12 +1470,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", - "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", + "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1493,25 +1493,25 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", - "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz", + "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", @@ -1533,20 +1533,20 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.18.tgz", - "integrity": "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.19.tgz", + "integrity": "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.18", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -1565,25 +1565,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", - "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.17.tgz", + "integrity": "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.6" + "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", @@ -1601,19 +1601,19 @@ } }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.10.tgz", - "integrity": "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.11.tgz", + "integrity": "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", @@ -1635,16 +1635,16 @@ } }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.5.tgz", - "integrity": "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.6.tgz", + "integrity": "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" @@ -1665,22 +1665,22 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", - "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz", + "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", @@ -1702,16 +1702,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", - "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz", + "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", @@ -1734,12 +1734,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", - "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -1781,9 +1781,9 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -1804,13 +1804,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.10.tgz", - "integrity": "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.11.tgz", + "integrity": "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw==", "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1828,9 +1828,9 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz", - "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.2.tgz", + "integrity": "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -1838,8 +1838,8 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" @@ -1860,18 +1860,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", - "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", + "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, @@ -1891,9 +1891,9 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", - "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.13.tgz", + "integrity": "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", @@ -1902,7 +1902,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -1922,31 +1922,31 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", - "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz", + "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.6", + "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1966,12 +1966,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", - "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", + "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -1989,18 +1989,18 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.1.tgz", - "integrity": "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.2.tgz", + "integrity": "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", @@ -2040,15 +2040,15 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz", - "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz", + "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" @@ -2069,9 +2069,9 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", - "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", + "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -2079,8 +2079,8 @@ "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -2099,23 +2099,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.17.tgz", - "integrity": "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.18.tgz", + "integrity": "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.6" + "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", @@ -2133,13 +2133,13 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.12.tgz", - "integrity": "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.13.tgz", + "integrity": "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -2158,17 +2158,17 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.13.tgz", - "integrity": "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.14.tgz", + "integrity": "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", - "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-toggle": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -2187,18 +2187,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.13.tgz", - "integrity": "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.14.tgz", + "integrity": "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", - "@radix-ui/react-separator": "1.1.10", - "@radix-ui/react-toggle-group": "1.1.13" + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-toggle-group": "1.1.14" }, "peerDependencies": { "@types/react": "*", @@ -2216,23 +2216,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", - "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz", + "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-visually-hidden": "1.2.6" + "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", @@ -2302,9 +2302,9 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", + "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", "license": "MIT", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" @@ -2401,12 +2401,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", - "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", + "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -2455,16 +2455,6 @@ } } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -2742,9 +2732,9 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { @@ -2754,37 +2744,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -2799,9 +2789,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -2816,9 +2806,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -2833,9 +2823,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -2850,9 +2840,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -2867,13 +2857,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2884,13 +2877,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2901,13 +2897,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2918,13 +2917,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2935,9 +2937,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2953,9 +2955,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -2965,18 +2967,18 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", @@ -2986,7 +2988,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", + "version": "1.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -3031,9 +3033,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -3048,9 +3050,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -3065,15 +3067,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -3374,9 +3376,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, "license": "MIT", "dependencies": { @@ -3423,17 +3425,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/type-utils": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3446,7 +3448,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.0", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3462,16 +3464,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3487,14 +3489,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.0", - "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3509,14 +3511,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3527,9 +3529,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -3544,15 +3546,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3569,9 +3571,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -3583,16 +3585,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.0", - "@typescript-eslint/tsconfig-utils": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3624,16 +3626,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3648,13 +3650,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3985,9 +3987,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4031,9 +4033,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -4051,11 +4053,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4081,9 +4083,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -4237,9 +4239,9 @@ "license": "MIT" }, "node_modules/cronstrue": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.21.0.tgz", - "integrity": "sha512-6DgX87NpjknhNmYC0/JjexQIAwCvtaUNoayqxLsciU5NkwGcEhuXLgXkIOX/bsuXL4f/aaLfgimJ6/2X8c5ezg==", + "version": "3.24.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.24.0.tgz", + "integrity": "sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==", "license": "MIT", "bin": { "cronstrue": "bin/cli.js" @@ -4638,18 +4640,18 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.383", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz", + "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==", "dev": true, "license": "ISC" }, @@ -4728,9 +4730,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", "workspaces": [ @@ -5051,12 +5053,12 @@ "license": "ISC" }, "node_modules/framer-motion": { - "version": "12.41.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.41.0.tgz", - "integrity": "sha512-OHAMNiCEON1RDBlRGuulsN5AD8ptMjvk5QWfFmYmBLPZ3zFGIJe60kQucQQf4cez1OzQmjYBWDY+dYfISkUdqg==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.41.0", + "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -5267,9 +5269,9 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz", + "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -5905,9 +5907,9 @@ } }, "node_modules/lucide-react": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", - "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6844,12 +6846,12 @@ } }, "node_modules/motion": { - "version": "12.41.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.41.0.tgz", - "integrity": "sha512-avEDKE22rFPJqDr3Ttk7gMQpeaOmNik60NoJ5T0tj+RBCNvz21D3ArY3l4uitoeQ7eIpDqueWaO3pPYFv8JOVA==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", "license": "MIT", "dependencies": { - "framer-motion": "^12.41.0", + "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6870,9 +6872,9 @@ } }, "node_modules/motion-dom": { - "version": "12.41.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.41.0.tgz", - "integrity": "sha512-Lk3J39fOGg6xNr1KRZsN6usDyBf8aP7MEbUPez1VCughHt79OrP7VGqNrPyFL0riaT7WS8t9DRw1M3BHtM/xKw==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -6891,9 +6893,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -6917,11 +6919,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/obug": { "version": "2.1.1", @@ -7091,9 +7096,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -7194,66 +7199,66 @@ } }, "node_modules/radix-ui": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.0.tgz", - "integrity": "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.1.tgz", + "integrity": "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-accessible-icon": "1.1.10", - "@radix-ui/react-accordion": "1.2.14", - "@radix-ui/react-alert-dialog": "1.1.17", - "@radix-ui/react-arrow": "1.1.10", - "@radix-ui/react-aspect-ratio": "1.1.10", - "@radix-ui/react-avatar": "1.2.0", - "@radix-ui/react-checkbox": "1.3.5", - "@radix-ui/react-collapsible": "1.1.14", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-accessible-icon": "1.1.11", + "@radix-ui/react-accordion": "1.2.15", + "@radix-ui/react-alert-dialog": "1.1.18", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-aspect-ratio": "1.1.11", + "@radix-ui/react-avatar": "1.2.1", + "@radix-ui/react-checkbox": "1.3.6", + "@radix-ui/react-collapsible": "1.1.15", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-context-menu": "2.3.1", - "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-context-menu": "2.3.2", + "@radix-ui/react-dialog": "1.1.18", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-dropdown-menu": "2.1.18", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-dropdown-menu": "2.1.19", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", - "@radix-ui/react-form": "0.1.10", - "@radix-ui/react-hover-card": "1.1.17", - "@radix-ui/react-label": "2.1.10", - "@radix-ui/react-menu": "2.1.18", - "@radix-ui/react-menubar": "1.1.18", - "@radix-ui/react-navigation-menu": "1.2.16", - "@radix-ui/react-one-time-password-field": "0.1.10", - "@radix-ui/react-password-toggle-field": "0.1.5", - "@radix-ui/react-popover": "1.1.17", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-form": "0.1.11", + "@radix-ui/react-hover-card": "1.1.18", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-menubar": "1.1.19", + "@radix-ui/react-navigation-menu": "1.2.17", + "@radix-ui/react-one-time-password-field": "0.1.11", + "@radix-ui/react-password-toggle-field": "0.1.6", + "@radix-ui/react-popover": "1.1.18", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-progress": "1.1.10", - "@radix-ui/react-radio-group": "1.4.1", - "@radix-ui/react-roving-focus": "1.1.13", - "@radix-ui/react-scroll-area": "1.2.12", - "@radix-ui/react-select": "2.3.1", - "@radix-ui/react-separator": "1.1.10", - "@radix-ui/react-slider": "1.4.1", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-progress": "1.1.11", + "@radix-ui/react-radio-group": "1.4.2", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-scroll-area": "1.2.13", + "@radix-ui/react-select": "2.3.2", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-slider": "1.4.2", "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.1", - "@radix-ui/react-tabs": "1.1.15", - "@radix-ui/react-toast": "1.2.17", - "@radix-ui/react-toggle": "1.1.12", - "@radix-ui/react-toggle-group": "1.1.13", - "@radix-ui/react-toolbar": "1.1.13", - "@radix-ui/react-tooltip": "1.2.10", + "@radix-ui/react-switch": "1.3.2", + "@radix-ui/react-tabs": "1.1.16", + "@radix-ui/react-toast": "1.2.18", + "@radix-ui/react-toggle": "1.1.13", + "@radix-ui/react-toggle-group": "1.1.14", + "@radix-ui/react-toolbar": "1.1.14", + "@radix-ui/react-tooltip": "1.2.11", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.6" + "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", @@ -7458,9 +7463,9 @@ } }, "node_modules/recharts": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", - "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz", + "integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==", "license": "MIT", "workspaces": [ "www" @@ -7471,7 +7476,7 @@ "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", - "immer": "^10.1.1", + "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", @@ -7895,9 +7900,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, @@ -8087,16 +8092,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", - "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8358,16 +8363,16 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index bb22b8e5..99d18814 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,21 +18,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.17", - "@radix-ui/react-checkbox": "^1.3.5", - "@radix-ui/react-context-menu": "^2.3.1", - "@radix-ui/react-dialog": "^1.1.17", - "@radix-ui/react-dropdown-menu": "^2.1.18", - "@radix-ui/react-hover-card": "^1.1.17", - "@radix-ui/react-label": "^2.1.10", - "@radix-ui/react-popover": "^1.1.17", - "@radix-ui/react-scroll-area": "^1.2.12", - "@radix-ui/react-select": "^2.3.1", - "@radix-ui/react-separator": "^1.1.10", - "@radix-ui/react-slider": "^1.4.1", + "@radix-ui/react-alert-dialog": "^1.1.18", + "@radix-ui/react-checkbox": "^1.3.6", + "@radix-ui/react-context-menu": "^2.3.2", + "@radix-ui/react-dialog": "^1.1.18", + "@radix-ui/react-dropdown-menu": "^2.1.19", + "@radix-ui/react-hover-card": "^1.1.18", + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-popover": "^1.1.18", + "@radix-ui/react-scroll-area": "^1.2.13", + "@radix-ui/react-select": "^2.3.2", + "@radix-ui/react-separator": "^1.1.11", + "@radix-ui/react-slider": "^1.4.2", "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.15", - "@radix-ui/react-tooltip": "^1.2.10", + "@radix-ui/react-tabs": "^1.1.16", + "@radix-ui/react-tooltip": "^1.2.11", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -41,42 +41,43 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "cronstrue": "^3.21.0", + "cronstrue": "^3.24.0", "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.21.0", + "lucide-react": "^1.23.0", "monaco-editor": "^0.55.1", - "motion": "^12.41.0", + "motion": "^12.42.2", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.0", + "radix-ui": "^1.6.1", "react": "^19.2.7", "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.9.0", + "recharts": "^3.9.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "yaml": "^2.9.0" }, "overrides": { - "dompurify": "^3.4.0" + "dompurify": "^3.4.11", + "@babel/core": "^7.29.6" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@tailwindcss/vite": "^4.3.1", + "@tailwindcss/vite": "^4.3.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.5.0", + "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", @@ -84,8 +85,8 @@ "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0", - "vite": "^8.1.0", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.2", "vitest": "^4.1.9" } } diff --git a/frontend/public/sencho-logo-dark.png b/frontend/public/sencho-logo-dark.png deleted file mode 100644 index 525771ee..00000000 Binary files a/frontend/public/sencho-logo-dark.png and /dev/null differ diff --git a/frontend/public/sencho-logo-dark.svg b/frontend/public/sencho-logo-dark.svg new file mode 100644 index 00000000..e5db2be2 --- /dev/null +++ b/frontend/public/sencho-logo-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/sencho-logo-light.png b/frontend/public/sencho-logo-light.png deleted file mode 100644 index 9ada4de0..00000000 Binary files a/frontend/public/sencho-logo-light.png and /dev/null differ diff --git a/frontend/public/sencho-logo-light.svg b/frontend/public/sencho-logo-light.svg new file mode 100644 index 00000000..02796050 --- /dev/null +++ b/frontend/public/sencho-logo-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/DeployFeedbackModal.tsx b/frontend/src/components/DeployFeedbackModal.tsx index 225d2997..47e03b86 100644 --- a/frontend/src/components/DeployFeedbackModal.tsx +++ b/frontend/src/components/DeployFeedbackModal.tsx @@ -229,7 +229,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM rowCount={logRows.length} errorMessage={errorMessage} countdown={countdown} - showCountdown={!gateHoldsOpen} + showCountdown={canAutoClose} gateStatus={healthGate?.status ?? null} />
{recoveryResult && loadingAction == null && ( @@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} + stackMuteActions={stackMuteActions} /> )} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 995b02fd..e2a5a1cf 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) { onRefreshState, onDismissRecovery, panelStartedAt, + stackMuteActions, } = props; const [segment, setSegment] = useState('logs'); @@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + stackMuteActions={stackMuteActions} /> @@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={canEditStack} notifications={notifications} + stackMuteActions={stackMuteActions} /> )} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index a10cee12..36bc0295 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -12,6 +12,7 @@ import ResourcesView from '../ResourcesView'; import HomeDashboard from '../HomeDashboard'; import type { NotificationItem } from '../dashboard/types'; import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import type { ActiveView } from './hooks/useViewNavigationState'; import type { SecurityTab, FleetTab } from '@/lib/events'; @@ -81,9 +82,12 @@ export interface ViewRouterProps { onClearScheduledOpsFilter: () => void; schedulePrefill: ScheduleTaskPrefill | null; onPrefillConsumed: () => void; + muteRulePrefill: MuteRuleDraft | null; + onMutePrefillConsumed: () => void; notifications: NotificationItem[]; onNavigateToStack: (stackFile: string) => void; onOpenSettingsSection: (section: SectionId) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; onClearNotifications: () => void; securityTab: SecurityTab; onSecurityTabChange: (tab: SecurityTab) => void; @@ -110,9 +114,12 @@ export function ViewRouter({ onClearScheduledOpsFilter, schedulePrefill, onPrefillConsumed, + muteRulePrefill, + onMutePrefillConsumed, notifications, onNavigateToStack, onOpenSettingsSection, + onOpenMuteRulesWithPrefill, onClearNotifications, securityTab, onSecurityTabChange, @@ -128,6 +135,9 @@ export function ViewRouter({ ); } @@ -186,6 +196,7 @@ export function ViewRouter({ Promise; scanStackConfig: () => Promise; requestDeleteStack: () => void; + stackMuteActions?: ReturnType; } // Breadcrumb + serif title + state pill + image ref + action bar. The action @@ -154,6 +157,7 @@ export function StackIdentityHeader({ rollbackStack, scanStackConfig, requestDeleteStack, + stackMuteActions, }: StackIdentityHeaderProps) { return (
@@ -228,8 +232,9 @@ export function StackIdentityHeader({ const canDelete = can('stack:delete', 'stack', stackName); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; + const canMute = stackMuteActions?.canMute ?? false; const hasOverflowExtras = canRollback || canScan; - const hasOverflow = hasOverflowExtras || canDelete; + const hasOverflow = hasOverflowExtras || canDelete || canMute; if (!canDeploy && !hasOverflow) return null; return (
@@ -287,7 +292,8 @@ export function StackIdentityHeader({ {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} - {hasOverflowExtras && canDelete && } + {stackMuteActions && } + {(canRollback || canScan || stackMuteActions?.canMute) && canDelete && } {canDelete && ( ({ + useNodes: () => ({ hasCapability: () => false }), +})); + // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 0c8dda6f..1e02f8c0 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -2,6 +2,15 @@ import { useCallback } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { buildServiceUrl } from '@/lib/serviceUrl'; +import { + createMuteRuleWithToast, + stackMuteAllDraft, + stackMuteDeploySuccessDraft, + stackMuteMonitorDraft, + labelMuteAllDraft, + labelMuteExternalDraft, + labelMuteLowPriorityDraft, +} from '@/lib/muteRules'; import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; import type { Label as StackLabel, LabelColor } from '../../label-types'; import type { OverlayState } from './useOverlayState'; @@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions'; import type { useStackListState } from './useStackListState'; import type { useViewNavigationState } from './useViewNavigationState'; import type { Node } from '@/context/NodeContext'; +import { useNodes } from '@/context/NodeContext'; import type { PermissionAction } from '@/context/AuthContext'; type StackListState = ReturnType; @@ -33,6 +43,7 @@ export function useSidebarContextMenu({ isAdmin, can, }: UseSidebarContextMenuOptions) { + const { hasCapability } = useNodes(); const buildMenuCtx = useCallback((file: string): StackMenuCtx => { const sName = file.replace(/\.(yml|yaml)$/, ''); const mainPort = stackListState.stackPorts[file]; @@ -40,6 +51,8 @@ export function useSidebarContextMenu({ // lifecycle affordances; the menu's status union stays three-state. const rawStatus = stackListState.stackStatuses[file] ?? 'unknown'; const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus; + const nodeId = activeNode?.id ?? null; + const canMuteNotifications = isAdmin && hasCapability('notification-suppression'); return { stackStatus, // Only offer "Open App" when a browser-reachable URL can actually be built @@ -125,6 +138,31 @@ export function useSidebarContextMenu({ navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null }); navState.setActiveView('scheduled-ops'); }, + canMuteNotifications, + muteStackAll: () => { + void createMuteRuleWithToast(stackMuteAllDraft(sName, nodeId)); + }, + muteStackDeploySuccess: () => { + void createMuteRuleWithToast(stackMuteDeploySuccessDraft(sName, nodeId)); + }, + muteStackMonitor: () => { + void createMuteRuleWithToast(stackMuteMonitorDraft(sName, nodeId)); + }, + openStackMuteRules: () => { + navState.openMuteRulesWithPrefill(stackMuteAllDraft(sName, nodeId)); + }, + muteLabelAll: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId)); + }, + muteLabelExternal: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId)); + }, + muteLabelLowPriority: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId)); + }, + openLabelMuteRules: (labelId: number, labelName: string) => { + navState.openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId)); + }, }; // Handlers from useStackActions, useOverlayState, useViewNavigationState are // useCallback-stabilized at their owner hooks, so listing the menu surface @@ -134,7 +172,8 @@ export function useSidebarContextMenu({ }, [ stackListState.stackStatuses, stackListState.stackPorts, isAdmin, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, - stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, + stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id, + hasCapability, navState.openMuteRulesWithPrefill, ]); return buildMenuCtx; diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 2e31133e..41c195ff 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager'; import type { SecurityTab, FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView'; +import type { MuteRuleDraft } from '@/lib/muteRules'; export type ActiveView = | 'dashboard' @@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const [fleetTab, setFleetTab] = useState(null); const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); + const [muteRulePrefill, setMuteRulePrefill] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); const handleOpenSettings = useCallback((section?: SectionId) => { @@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) }, []); const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); + const handleMutePrefillConsumed = useCallback(() => setMuteRulePrefill(null), []); + + const openMuteRulesWithPrefill = useCallback((draft: MuteRuleDraft) => { + setMuteRulePrefill(draft); + setSettingsSection('notification-suppression'); + setActiveView('settings'); + setFilterNodeId(null); + }, []); const handleNavigate = useCallback((value: string) => { if (value === activeView) return; @@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) fleetTab, setFleetTab, filterNodeId, setFilterNodeId, schedulePrefill, setSchedulePrefill, + muteRulePrefill, setMuteRulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, + handleMutePrefillConsumed, + openMuteRulesWithPrefill, handleNavigate, navItems, } as const; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index e24b2b91..6e268256 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { RefreshCw, Camera, FileDown, Network, SlidersHorizontal, - Send, KeyRound, ArrowLeftRight, Wrench, Workflow, + Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag, } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FleetMasthead } from './fleet/FleetMasthead'; @@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; +import { useNodes } from '@/context/NodeContext'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; @@ -30,14 +31,17 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab'; import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab'; import { SecretsTab } from './fleet/secrets/SecretsTab'; import { DependencyMapTab } from './fleet/DependencyMapTab'; +import { ContainerLabelsTab } from './fleet/ContainerLabelsTab'; import { useNodeActions } from './nodes/useNodeActions'; import type { FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; +import type { MuteRuleDraft } from '@/lib/muteRules'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; /** Opens a Settings section (used to send "Add node" to Settings > Nodes). */ onOpenSettingsSection?: (section: SectionId) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; onFleetUpdatesIntentConsumed?: () => void; /** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */ @@ -45,9 +49,11 @@ interface FleetViewProps { onFleetTabConsumed?: () => void; } -export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { +export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); + const { hasCapability } = useNodes(); + const containerLabelsEnabled = hasCapability('container-label-inventory'); const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -131,6 +137,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate Map + {containerLabelsEnabled && ( + + + Docker Labels + + + )} {isPaid && ( @@ -234,6 +247,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate onCordonChange={() => { void overview.fetchOverview(true); }} onEditNode={isAdmin ? openEdit : undefined} onDeleteNode={isAdmin ? openDelete : undefined} + onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} checkingUpdates={updateStatus.checkingUpdates} @@ -255,6 +269,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate + {containerLabelsEnabled && ( + + + + )} {isPaid && ( diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 587a933d..8c95177f 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -14,6 +14,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems'; +import { useNodeMuteActions } from '@/hooks/useMuteRuleActions'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import { formatBytes } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -42,6 +45,7 @@ export interface NodeCardProps { onCordonChange?: () => void; onEdit?: (node: Node) => void; onDelete?: (node: Node) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } // --- Sub-Components --- @@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { // --- Main Export --- -export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) { +export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -77,7 +81,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u // (requirePermission('node:manage','node',id) + requirePaid). Gating on tier // alone would surface the control to deployer/viewer/auditor users whose calls 403. const canCordon = isPaid && can('node:manage', 'node', String(node.id)); - const showMenu = canEdit || canDelete || canCordon; + const nodeMuteActions = useNodeMuteActions( + node.id, + node.name, + onOpenMuteRulesWithPrefill ?? (() => {}), + ); + const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; @@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u {node.cordoned ? 'Uncordon node' : 'Cordon node'} )} + {onOpenMuteRulesWithPrefill && }
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 50cbaade..d4c9bce1 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet- import type { Label as StackLabel } from '../label-types'; import type { Node } from '@/context/NodeContext'; import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types'; +import type { MuteRuleDraft } from '@/lib/muteRules'; interface OverviewTabProps { loading: boolean; @@ -35,6 +36,7 @@ interface OverviewTabProps { onCordonChange?: () => void; onEditNode?: (node: Node) => void; onDeleteNode?: (node: Node) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; topologyPositions: SavedPositions; @@ -70,6 +72,7 @@ export function OverviewTab({ onCordonChange, onEditNode, onDeleteNode, + onOpenMuteRulesWithPrefill, topologyMode, onTopologyModeChange, topologyPositions, @@ -149,6 +152,7 @@ export function OverviewTab({ onCordonChange={onCordonChange} onEdit={onEditNode} onDelete={onDeleteNode} + onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} /> ))}
diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index 8c526b7f..c0d1b726 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -34,7 +34,7 @@ function baseProps(node: FleetNode) { } beforeEach(() => { - useNodesMock.mockReturnValue({ nodes: [] }); + useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) }); useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); useLicenseMock.mockReturnValue({ isPaid: false }); }); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index dc1187c3..5a7b4e82 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -9,6 +9,7 @@ import { Trash2, SlidersHorizontal, CheckCheck, + MoreHorizontal, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -22,10 +23,18 @@ import { SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import type { NotificationCategory, NotificationItem } from './dashboard/types'; -import type { Node } from '@/context/NodeContext'; +import { createMuteFromNotification } from '@/lib/muteRules'; +import { useAuth } from '@/context/AuthContext'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { CATEGORY_LABELS } from '@/lib/notificationCategories'; import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility'; +import type { NotificationCategory, NotificationItem } from './dashboard/types'; +import type { Node } from '@/context/NodeContext'; const NODE_FILTER_ALL = 'all' as const; const CATEGORY_FILTER_ALL = 'all' as const; @@ -128,6 +137,7 @@ export function NotificationPanel({ onNavigate, onNavigateChangelog, }: NotificationPanelProps) { + const { isAdmin } = useAuth(); const [filter, setFilter] = useState('all'); const [nodeFilter, setNodeFilter] = useState(NODE_FILTER_ALL); const [categoryFilter, setCategoryFilter] = useState(CATEGORY_FILTER_ALL); @@ -369,6 +379,7 @@ export function NotificationPanel({ onDelete={onDelete} onNavigate={onNavigate ? handleNavigate : undefined} onNavigateChangelog={onNavigateChangelog} + canMute={isAdmin} /> ))} @@ -386,9 +397,17 @@ interface NotificationRowProps { onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; onNavigateChangelog?: (notif: NotificationItem) => void; + canMute?: boolean; } -function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) { +async function createQuickSuppressionRule( + notif: NotificationItem, + mode: 'category' | 'similar' | 'stack', +): Promise { + await createMuteFromNotification(notif, mode); +} + +function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) { const config = LEVEL_CONFIG[notif.level]; const Icon = config.icon; const isUnread = !notif.is_read; @@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate ) : (
{content}
)} - +
+ {canMute ? ( + + + + + + {notif.category ? ( + void createQuickSuppressionRule(notif, 'category')}> + Mute this category + + ) : null} + void createQuickSuppressionRule(notif, 'similar')}> + Mute notifications like this + + {notif.stack_name ? ( + void createQuickSuppressionRule(notif, 'stack')}> + Mute this stack + + ) : null} + + + ) : null} + +
); } diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 6ec599b2..a529bb1a 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -14,6 +14,7 @@ import { toast } from '@/components/ui/toast-store'; import { copyToClipboard } from '@/lib/clipboard'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch, fetchForNode } from '@/lib/api'; +import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter'; import { Combobox } from '@/components/ui/combobox'; import { SegmentedControl } from '@/components/ui/segmented-control'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; @@ -48,6 +49,18 @@ const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = { const TIMELINE_WINDOW_HOURS = 24; const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; +interface ContainerListItem { + Id: string; + Names?: string[]; + State?: string; + Image?: string; + Labels?: Record; +} + +function containerDisplayName(c: ContainerListItem): string { + return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12); +} + function formatHourTick(ts: number): string { const d = new Date(ts); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; @@ -108,8 +121,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [runsTotal, setRunsTotal] = useState(0); const runsLimit = 20; - // Available stacks and nodes for selection + // Available stacks, containers, and nodes for selection const [stacks, setStacks] = useState([]); + const [containers, setContainers] = useState([]); const [nodes, setNodes] = useState([]); const filteredTasks = filterNodeId != null @@ -148,6 +162,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p } }, []); + const fetchContainers = useCallback(async (nodeId: string) => { + try { + const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10)); + if (res.ok) { + const rows = (await res.json()) as ContainerListItem[]; + setContainers(excludeLikelySenchoContainers(rows)); + } else { + setContainers([]); + } + } catch { + setContainers([]); + } + }, []); + const fetchNodes = useCallback(async () => { try { const res = await apiFetch('/nodes', { localOnly: true }); @@ -194,7 +222,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ? await fetchForNode(endpoint, parseInt(formNodeId, 10)) : await apiFetch(endpoint); if (res.ok && !cancelled) { - setAvailableServices(await res.json()); + const services = (await res.json()) as string[]; + setAvailableServices(services); + if (services.length <= 1) { + setFormTargetServices([]); + } } } catch { // Non-critical @@ -204,17 +236,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p return () => { cancelled = true; }; }, [formAction, formTargetId, formNodeId]); - // Re-fetch stacks when the node changes. Clearing a stale stack selection is - // done in the Node picker's onValueChange (a user-driven change), not here, so - // a prefilled or edited node keeps its stack instead of being wiped on open. useEffect(() => { if (!dialogOpen) return; - if (formNodeId) { + const actionDef = getActionById(formAction); + if (actionDef?.requiresContainer && formNodeId) { + fetchContainers(formNodeId); fetchStacks(formNodeId); + } else if (formNodeId) { + fetchStacks(formNodeId); + setContainers([]); } else { setStacks([]); + setContainers([]); } - }, [formNodeId, dialogOpen, fetchStacks]); + }, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]); const openCreate = (prefillData?: { stackName: string; nodeId: string }) => { const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); @@ -301,7 +336,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p enabled: formEnabled, delete_after_run: formDeleteAfterRun, run_at: runAt, - target_id: actionDef.requiresStack ? formTargetId : null, + target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null, node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null, prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null, target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null, @@ -455,13 +490,31 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p [nodes], ); const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions; + const containerOptions = useMemo( + () => containers.map(c => { + const name = containerDisplayName(c); + const state = c.State ?? 'unknown'; + const image = (c.Image ?? '').split('@')[0]; + return { value: name, label: `${name} · ${state} · ${image}` }; + }), + [containers], + ); + const selectedContainer = useMemo( + () => containers.find(c => containerDisplayName(c) === formTargetId), + [containers, formTargetId], + ); + const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project']; + const isUnmanagedContainer = !!selectedContainer && ( + !selectedContainerStack || !stacks.includes(selectedContainerStack) + ); const scheduleInvalid = scheduleMode === 'simple' ? !!simpleCronError : (!formCron || !!cronFieldError); const isSaveDisabled = saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) - || (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId) + || (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId)) + || (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0); const windowEnd = now + TIMELINE_WINDOW_MS; @@ -689,6 +742,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ? task.target_services ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` : task.target_id + : task.target_type === 'container' + ? task.target_id : task.action === 'update' ? 'All eligible stacks' : task.target_type} @@ -817,6 +872,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )} + {currentAction?.requiresContainer && ( + <> +
+ + { setFormNodeId(val); setFormTargetId(''); }} + placeholder="Select node..." + /> +
+
+ + { setFormTargetId(val); setFormTargetServices([]); }} + placeholder={formNodeId ? 'Select container...' : 'Select a node first'} + disabled={!formNodeId} + /> +
+ {isUnmanagedContainer && ( +

+ This container is not associated with a Sencho stack. The schedule will target the container by node and name. +

+ )} + {selectedContainerStack && stacks.includes(selectedContainerStack) && ( +

+ Part of stack: {selectedContainerStack} +

+ )} + + )} + {currentAction?.requiresStack && ( <>
@@ -833,12 +922,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ({ value: s, label: s }))} value={formTargetId} - onValueChange={setFormTargetId} + onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }} placeholder={formNodeId ? "Select stack..." : "Select a node first"} disabled={!formNodeId} />
- {currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && ( + {currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
@@ -871,7 +960,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
)} - {currentAction?.requiresNode && !currentAction.requiresStack && ( + {currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
- +