diff --git a/.github/workflows/auto-pr-description.yaml b/.github/workflows/auto-pr-description.yaml index 263b2dc11..d9d042439 100644 --- a/.github/workflows/auto-pr-description.yaml +++ b/.github/workflows/auto-pr-description.yaml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3056730ab..b60ce477f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node uses: actions/setup-node@v6 with: @@ -36,7 +36,7 @@ jobs: STATIC: true steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Go @@ -87,7 +87,7 @@ jobs: - { on: windows-latest, goos: windows, goarch: arm64 } steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/cleanup-branches.yaml b/.github/workflows/cleanup-branches.yaml index a0382041d..41fca2064 100644 --- a/.github/workflows/cleanup-branches.yaml +++ b/.github/workflows/cleanup-branches.yaml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 71a9bd647..59be670c9 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Normalize image name id: image_name diff --git a/.github/workflows/deploy-testing.yml b/.github/workflows/deploy-testing.yml index b8e7d3d87..caaa199fe 100644 --- a/.github/workflows/deploy-testing.yml +++ b/.github/workflows/deploy-testing.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Normalize image name id: image_name diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 7b699d923..3e847f771 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -101,7 +101,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -161,7 +161,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -221,7 +221,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -281,7 +281,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -341,7 +341,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Populate frontend version information run: cd webapp/frontend && ./git.version.sh @@ -402,7 +402,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Populate frontend version information run: cd webapp/frontend && ./git.version.sh diff --git a/.github/workflows/release-and-deploy.yml b/.github/workflows/release-and-deploy.yml index 3f4061fb7..3c3acdfde 100644 --- a/.github/workflows/release-and-deploy.yml +++ b/.github/workflows/release-and-deploy.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Normalize image name id: image_name diff --git a/.github/workflows/release-frontend.yaml b/.github/workflows/release-frontend.yaml index bda2f62da..8f2e07dd3 100644 --- a/.github/workflows/release-frontend.yaml +++ b/.github/workflows/release-frontend.yaml @@ -16,7 +16,7 @@ jobs: container: node:lts-slim steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name }} - name: Generate frontend version information diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index be257b62e..d99f034de 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,7 +28,7 @@ jobs: new_release_version: ${{ steps.semantic.outputs.new_release_version }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false @@ -155,7 +155,7 @@ jobs: - { on: windows-latest, goos: windows, goarch: arm64 } steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: v${{ needs.release.outputs.new_release_version }} # Build from the published release tag so the binaries embed the same diff --git a/.github/workflows/security-scan.yaml b/.github/workflows/security-scan.yaml index 4ee2b2997..4fb34b400 100644 --- a/.github/workflows/security-scan.yaml +++ b/.github/workflows/security-scan.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Run Trivy vulnerability scanner (SARIF) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # 0.36.0 diff --git a/.github/workflows/sonarqube.yaml b/.github/workflows/sonarqube.yaml index fc588efee..d7ae759ce 100644 --- a/.github/workflows/sonarqube.yaml +++ b/.github/workflows/sonarqube.yaml @@ -30,7 +30,7 @@ jobs: STATIC: true steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Go @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/sync-develop.yaml b/.github/workflows/sync-develop.yaml index 2a5b29001..cff778369 100644 --- a/.github/workflows/sync-develop.yaml +++ b/.github/workflows/sync-develop.yaml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -67,7 +67,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 069448884..09dab2228 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,10 @@ scratch.md # Scratch Scripts scratch.go test_flux.go + +# Local architecture knowledge base (agent/maintainer guidance, not shipped) +CONTEXT.md +docs/adr/ +docs/agents/invariants.md +docs/agents/danger-zones.md +docs/agents/review-checklist.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 294cd7b9d..ebcf9b6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Scrutiny will be documented in this file. +## [1.66.0](https://github.com/Starosdev/scrutiny/compare/v1.65.0...v1.66.0) (2026-07-08) + +### Features + +* **release:** promote beta to master ([#650](https://github.com/Starosdev/scrutiny/issues/650)) ([c963973](https://github.com/Starosdev/scrutiny/commit/c9639733617fe1833414f8073820caf57c4a1ebf)) + +### Bug Fixes + +* **ui:** dark mode tokens for settings dialog and dropdowns ([#634](https://github.com/Starosdev/scrutiny/issues/634)) ([c38d06f](https://github.com/Starosdev/scrutiny/commit/c38d06ff1d9bf1ef09eaaafaa6a91734b1ec3030)) + +## Unreleased + +### Bug Fixes + +* **smart:** skip generic HDD observed-threshold scoring for vendor-specific ATA SSD attributes and add a Crucial RealSSD compatibility profile ([#587](https://github.com/Starosdev/scrutiny/issues/587)) + ## [1.65.0](https://github.com/Starosdev/scrutiny/compare/v1.64.0...v1.65.0) (2026-06-29) ### Features diff --git a/Makefile b/Makefile index 34730b9d4..a5350405e 100644 --- a/Makefile +++ b/Makefile @@ -103,6 +103,14 @@ binary-test: binary-dep binary-test-coverage: binary-dep go test -coverprofile=coverage.txt -covermode=atomic -v $(STATIC_TAGS) ./... +.PHONY: catalog-lint +catalog-lint: + go run ./webapp/backend/cmd/catalog-lint -strict + +.PHONY: catalog-fix +catalog-fix: + go run ./webapp/backend/cmd/catalog-lint -write + .PHONY: binary-collector binary-collector: binary-dep go build -buildvcs=false -ldflags "$(LD_FLAGS)" -o $(COLLECTOR_BINARY_NAME) $(STATIC_TAGS) ./collector/cmd/collector-metrics/ diff --git a/docs/API.md b/docs/API.md index 9396b071c..7cee7cc0a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -43,6 +43,7 @@ See [AUTH.md](./AUTH.md) for configuration and deployment details. - Some collector payloads are intentionally documented as structured objects with representative fields because the backend accepts large collector-origin JSON models. - `GET /api/device/{id}/selftest` returns ATA SMART self-test history already recorded during normal SMART uploads. The separate `POST /api/device/{id}/selftest` route remains reserved for future ingestion work. - Notification URL endpoints cover existing Shoutrrr syntax, explicit `apprise+...` targets, `script://` targets, and raw `http(s)` webhooks. -- The replacement-risk endpoint includes ATA-specific metadata describing whether a bundled consumer-drive profile was enabled and applied for that score. +- The replacement-risk endpoint includes ATA-specific metadata describing whether a bundled consumer-drive profile was enabled and applied for that score, plus provenance fields (source, sample count, match method, catalog version) when a profile is applied. +- `GET /api/device/{id}/drive-profile` is a debug surface reporting the full consumer-drive profile match path: match method, confidence gate result, applied overrides, and fallback reason. - For operator-facing behavior and the global opt-out setting, see [CONSUMER_DRIVE_PROFILES.md](./CONSUMER_DRIVE_PROFILES.md). - If a route is added or changed in `server.go`, update `openapi.yaml` in the same change. diff --git a/docs/CONSUMER_DRIVE_PROFILES.md b/docs/CONSUMER_DRIVE_PROFILES.md index d9e6bc9e1..4448cb0c9 100644 --- a/docs/CONSUMER_DRIVE_PROFILES.md +++ b/docs/CONSUMER_DRIVE_PROFILES.md @@ -15,31 +15,50 @@ This applies only to ATA devices. NVMe and SCSI devices continue to use their ex ## Matching Behavior -Scrutiny tries to match profiles in this order: +Scrutiny tries to match profiles in this order, strongest first. The stage that matched is reported as the machine-readable `match_method`: -1. `model_family` reported by `smartctl` -2. normalized exact `model_name` -3. regex-style `model_pattern` fallback +| Order | Match method | Meaning | +| ----- | ------------- | ------- | +| 1 | `model_family` | Exact match on the `model_family` reported by `smartctl` | +| 2 | `model_name` | Exact match on the normalized `model_name` (alias table) | +| 3 | `model_name_normalized` | Exact match after vendor-aware normalization (see below) | +| 4 | `model_pattern` | Regex `model_pattern` fallback | + +Exact hits (`model_family`, `model_name`) are the strongest signals; `model_name_normalized` is nearly as strong because it still resolves to an exact catalog entry; `model_pattern` is the weakest because a regex can overreach. If no vetted profile matches, Scrutiny falls back to the existing generic ATA rules. +### Vendor-aware normalization + +The `model_name_normalized` stage strips well-known decorations from real-world model strings before retrying an exact lookup: + +- capacity suffixes: `Samsung SSD 870 EVO 2TB` -> `Samsung SSD 870 EVO` +- WDC firmware suffixes: `WDC WD80EFAX-68LHPN0` -> `WDC WD80EFAX` +- Seagate firmware suffixes: `ST4000DM000-1F2168` -> `ST4000DM000` + +This catches common variants (new capacity sizes, new firmware revisions) without broad regex rules that could create false positives. + +### Catalog source + The bundled catalog is validated at startup and loaded from: - [consumer_drive_profiles.json](../webapp/backend/pkg/thresholds/consumer_drive_profiles.json) -The catalog is shipped in-repo and embedded into the backend binary. Scrutiny does not fetch profile data at runtime. +The catalog is shipped in-repo and embedded into the backend binary. Scrutiny does not fetch profile data at runtime. The catalog carries a `version` string that is reported through the API for provenance. ## Confidence Gate Profiles only apply when the catalog entry meets the minimum confidence threshold. - Default minimum sample count: `20` -- A profile may raise that threshold if needed +- A profile may raise that threshold via `min_samples` Low-confidence entries are ignored and fall back to generic ATA behavior. ## User Control +### Global toggle + The feature is enabled by default and can be disabled globally in the dashboard: - `Settings` -> `Consumer Drive Profiles` @@ -50,6 +69,37 @@ Persisted setting key: When disabled, Scrutiny uses generic ATA rules even for drives that would otherwise match a profile. +### Per-family denylist + +Operators can exclude specific profile families without disabling the whole feature: + +- `Settings` -> `Consumer Drive Profile Denylist` + +Persisted setting key: + +- `metrics.consumer_drive_profiles_denylist` + +The value is a comma-separated list of family names (for example `Seagate Barracuda 7200.14 (AF), Crucial MX500`). Names are matched case-insensitively after normalization, so `crucial mx500` and `Crucial MX500` are equivalent. + +### Precedence rules + +1. If the global toggle is off, no profiles are applied. The denylist is irrelevant. +2. If the global toggle is on, a matched profile is applied unless its family is denylisted or it fails its confidence gate. +3. A denylisted or low-confidence match does not end the lookup: weaker match stages are still consulted, so a drive whose family is denylisted can still match a different (allowed) family through its model name. In practice the stages almost always resolve to the same family. +4. If nothing survives, the drive uses generic ATA rules. + +## Debug and Inspection Surface + +`GET /api/device/{id}/drive-profile` reports the full override path for a device without code tracing: + +- whether the feature is enabled and what is denylisted +- whether the catalog matched the drive, via which `match_method`, and on which input value +- the matched family, vendor, source, sample count, and confidence gate result +- which ATA attributes would have observed-threshold or counter-severity overrides applied +- a plain-language `fallback_reason` when generic ATA rules are in effect + +The catalog match is computed even when the feature is globally disabled, so you can see what would happen; the `applied` flag reflects the effective state. + ## UI Behavior On ATA drive detail pages, the replacement-risk card reports which path was used: @@ -66,4 +116,63 @@ On ATA drive detail pages, the replacement-risk card reports which path was used - `consumer_drive_profile_applied` - `consumer_drive_profile_family` +When a profile is applied, the response also carries provenance fields (omitted when generic ATA logic is used): + +- `consumer_drive_profile_source` - curated dataset description +- `consumer_drive_profile_sample_count` - sample size behind the profile +- `consumer_drive_profile_match_method` - which lookup stage matched +- `consumer_drive_profile_catalog_version` - bundled catalog version + See [API.md](./API.md) and [openapi.yaml](./openapi.yaml) for the current contract. + +## Catalog Maintenance Workflow + +The catalog is a first-class artifact with its own lint pipeline. Before merging any catalog change, run: + +```bash +make catalog-lint # validate + lint + expected-match fixtures (strict: warnings fail) +make catalog-fix # rewrite the catalog in canonical form +``` + +Or invoke the tool directly for more control: + +```bash +go run ./webapp/backend/cmd/catalog-lint -help +``` + +The pipeline enforces three layers: + +1. **Hard validation** (also runs at startup; a violation prevents the backend from booting): + - profiles must declare `model_family`, a non-empty `source`, a positive `sample_count`, and protocol `ATA` + - duplicate families, conflicting aliases, aliases pointing at unknown families, and invalid regex patterns are rejected + - counter severity overrides must satisfy `low <= moderate <= high <= critical` + - observed-threshold buckets must have `low <= high`, an `annual_failure_rate` in `[0, 1]`, and an ordered two-value `error_interval` +2. **Lint warnings** (fail `make catalog-lint`, which runs in strict mode): + - dead entries that can never pass their own confidence gate + - regex patterns that shadow aliases or family names belonging to a different family + - duplicate patterns and redundant aliases + - a missing catalog `version` +3. **Expected-match fixtures** ([testdata/consumer_drive_profile_fixtures.json](../webapp/backend/pkg/thresholds/testdata/consumer_drive_profile_fixtures.json)): + - representative real-world model strings pinned to their expected family and match method + - matched and unmatched cases both covered, so catalog edits that unintentionally change existing matches fail fast + - the same fixtures run in `go test ./webapp/backend/pkg/thresholds/` + +To add or update catalog entries: + +1. Edit `consumer_drive_profiles.json` (add the profile, aliases, and optionally a narrow `model_pattern`). +2. Bump the catalog `version` field. +3. Add expected-match fixtures for the new entries, including at least one near-miss that must stay unmatched. +4. Run `make catalog-fix` to normalize formatting, then `make catalog-lint` until clean. +5. Commit the catalog and fixtures together. + +The generated canonical JSON remains the embedded runtime source of truth; there is no runtime fetching. + +## Decision Record: Per-Family Weight Overrides + +**Status: evaluated and deferred (2026-07).** + +We considered letting profiles override the per-attribute weights used by replacement-risk scoring (for example, weighting attribute 5 more heavily for families with strong reallocated-sector failure correlation). + +The bundled catalog's evidence base (curated linuxhw/SMART population aggregates) supports family-level severity breakpoints and observed-threshold buckets, but it does not provide per-attribute failure *correlation* data per family. Deriving weight overrides from it would manufacture precision the source cannot defend: sample counts in the tens-to-thousands range are sufficient to say "this family tolerates a few reallocated sectors" but not "reallocated sectors predict failure 1.4x better than pending sectors for this family." + +Per-family weight overrides therefore do not ship. The existing protocol-level weights (informed by Backblaze failure-correlation research across large mixed fleets) remain in effect for all ATA drives. This decision should be revisited only if a dataset with per-family, per-attribute failure correlation becomes available; at that point support should be added narrowly, with tests and an explicit fallback path, per the acceptance criteria in issue #552. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6b00c05a..29c7e4bac 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -621,6 +621,29 @@ paths: $ref: "#/components/schemas/ReplacementRiskResponse" "500": $ref: "#/components/responses/ErrorResponse" + /api/device/{id}/drive-profile: + get: + tags: [Devices] + summary: Inspect consumer drive profile matching for a device + description: | + Debug and inspection surface for the ATA consumer-drive profile system. + Reports which bundled catalog entry matched the device (if any), the match + method and confidence gate result, which overrides would be applied, and a + plain-language fallback reason when generic ATA rules are in effect. + The catalog match is computed even when the feature is globally disabled so + operators can inspect what would happen; the `applied` flag reflects the + effective state. + parameters: + - $ref: "#/components/parameters/DeviceId" + responses: + "200": + description: Drive profile match diagnostics + content: + application/json: + schema: + $ref: "#/components/schemas/DriveProfileInspectionResponse" + "404": + $ref: "#/components/responses/NotFound" /api/device/{id}/collector-error: post: tags: [Devices] @@ -1343,6 +1366,12 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Requested resource was not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" schemas: SuccessResponse: type: object @@ -1804,6 +1833,9 @@ components: consumer_drive_profiles_enabled: type: boolean description: Enable vetted ATA consumer drive model or family overrides for SMART status evaluation and replacement-risk scoring. When false, all ATA devices use generic ATA rules. + consumer_drive_profiles_denylist: + type: string + description: Comma-separated consumer drive profile families that must never be applied. Matching drives fall back to generic ATA rules. Ignored when `consumer_drive_profiles_enabled` is false (everything already falls back). Empty means no families are excluded. notification_rate_limit: type: integer notification_quiet_start: @@ -3287,6 +3319,19 @@ components: consumer_drive_profile_family: type: string description: Matched ATA consumer-drive profile family when `consumer_drive_profile_applied` is true. + consumer_drive_profile_source: + type: string + description: Provenance string of the applied profile (curated dataset description). Omitted when generic ATA logic is used. + consumer_drive_profile_sample_count: + type: integer + description: Sample size behind the applied profile. Omitted when generic ATA logic is used. + consumer_drive_profile_match_method: + type: string + enum: [model_family, model_name, model_name_normalized, model_pattern] + description: Lookup stage that matched the drive, strongest-first. Omitted when generic ATA logic is used. + consumer_drive_profile_catalog_version: + type: string + description: Version of the bundled profile catalog the applied profile came from. Omitted when generic ATA logic is used. contributions: type: array items: @@ -3305,6 +3350,72 @@ components: format: int64 trend_score: type: number + DriveProfileInspectionResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + device_wwn: + type: string + device_protocol: + type: string + model_family: + type: string + model_name: + type: string + profiles_enabled: + type: boolean + description: Global consumer drive profiles toggle state. + denylist: + type: array + items: + type: string + description: Normalized family keys currently denylisted in settings. + catalog_version: + type: string + description: Version of the bundled profile catalog. + matched: + type: boolean + description: True when the catalog recognized this drive, regardless of whether the profile was applied. + applied: + type: boolean + description: True when the matched profile is actually used for SMART status evaluation and replacement-risk scoring. + match_method: + type: string + enum: [model_family, model_name, model_name_normalized, model_pattern] + description: Lookup stage that matched the drive. + matched_value: + type: string + description: Input value (family or model name) that matched. + profile_family: + type: string + profile_vendor: + type: string + profile_source: + type: string + sample_count: + type: integer + min_samples: + type: integer + description: Confidence gate the matched profile must satisfy. + confidence_met: + type: boolean + observed_threshold_attributes: + type: array + items: + type: integer + description: ATA attribute IDs whose observed-threshold buckets are overridden during SMART status evaluation. + counter_severity_attributes: + type: array + items: + type: string + description: ATA attribute IDs whose counter severity breakpoints are overridden during replacement-risk scoring. + fallback_reason: + type: string + description: Plain-language explanation of why generic ATA rules are in effect. Empty when a profile is applied. MissedPingStatusResponse: type: object properties: diff --git a/go.mod b/go.mod index 114e3fa4d..ee6bd6b2d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/jaypipes/ghw v0.24.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/nicholas-fedor/shoutrrr v0.16.0 + github.com/nicholas-fedor/shoutrrr v0.16.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/robfig/cron/v3 v3.0.1 @@ -27,8 +27,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 go.uber.org/automaxprocs v1.6.0 - golang.org/x/sync v0.20.0 - gorm.io/gorm v1.31.1 + golang.org/x/sync v0.21.0 + gorm.io/gorm v1.31.2 ) require ( @@ -92,9 +92,9 @@ require ( golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d604b7167..d80584770 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+Licev github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -118,6 +118,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -129,8 +131,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nicholas-fedor/shoutrrr v0.16.0 h1:oUKfFZDM7CoENuZMsE/8sK/KbED92/urTWR0wv2BAq8= -github.com/nicholas-fedor/shoutrrr v0.16.0/go.mod h1:l0IveCVC6V2uyjxCeK22JTRlzoiQp2c0Dam/HE6OLBk= +github.com/nicholas-fedor/shoutrrr v0.16.1 h1:MOzl3U6zprA45lOTKaTbQW/9wzkR7SC36IFrTr5xfkU= +github.com/nicholas-fedor/shoutrrr v0.16.1/go.mod h1:lii4gQKKSV2c32b+D93ysEdKpEzVla6HTbyOajpNDpo= github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= @@ -224,8 +226,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -233,23 +235,23 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -268,8 +270,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw= howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/webapp/backend/cmd/catalog-lint/catalog-lint.go b/webapp/backend/cmd/catalog-lint/catalog-lint.go new file mode 100644 index 000000000..7cd63729d --- /dev/null +++ b/webapp/backend/cmd/catalog-lint/catalog-lint.go @@ -0,0 +1,126 @@ +// Command catalog-lint validates, lints, and canonicalizes the bundled ATA +// consumer drive profile catalog. Run it locally before merging catalog edits: +// +// go run ./webapp/backend/cmd/catalog-lint # validate + lint + fixtures +// go run ./webapp/backend/cmd/catalog-lint -write # also rewrite the catalog in canonical form +// go run ./webapp/backend/cmd/catalog-lint -strict # treat lint warnings as failures +// +// The tool exits non-zero on validation errors, fixture mismatches, or (with +// -strict) lint warnings. The canonical output written by -write is the exact +// byte form embedded into the backend binary at build time. +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + + "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" +) + +const ( + defaultCatalogPath = "webapp/backend/pkg/thresholds/consumer_drive_profiles.json" + defaultFixturesPath = "webapp/backend/pkg/thresholds/testdata/consumer_drive_profile_fixtures.json" +) + +func main() { + catalogPath := flag.String("catalog", defaultCatalogPath, "path to the profile catalog JSON") + fixturesPath := flag.String("fixtures", defaultFixturesPath, "path to the expected-match fixtures JSON (empty to skip)") + write := flag.Bool("write", false, "rewrite the catalog file in canonical form") + strict := flag.Bool("strict", false, "treat lint warnings as failures") + flag.Parse() + + if err := run(*catalogPath, *fixturesPath, *write, *strict); err != nil { + fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) + os.Exit(1) + } +} + +func run(catalogPath, fixturesPath string, write, strict bool) error { + data, err := os.ReadFile(catalogPath) + if err != nil { + return fmt.Errorf("read catalog: %w", err) + } + + // Hard validation + lint warnings. + lintResult, err := thresholds.LintConsumerDriveProfileCatalog(data) + if err != nil { + return fmt.Errorf("catalog validation failed: %w", err) + } + for _, warning := range lintResult.Warnings { + fmt.Printf("WARN: %s\n", warning) + } + + handle, err := thresholds.LoadConsumerDriveProfileCatalog(data) + if err != nil { + return fmt.Errorf("load catalog: %w", err) + } + fmt.Printf("OK: catalog is valid (version %q)\n", handle.Version()) + + // Expected-match fixtures. + if fixturesPath != "" { + if fixturesErr := runFixtures(handle, fixturesPath); fixturesErr != nil { + return fixturesErr + } + } + + // Canonical form check / rewrite. + if canonicalErr := checkCanonicalForm(catalogPath, data, write, strict); canonicalErr != nil { + return canonicalErr + } + + if strict && len(lintResult.Warnings) > 0 { + return fmt.Errorf("%d lint warning(s) in strict mode", len(lintResult.Warnings)) + } + return nil +} + +// checkCanonicalForm compares the catalog against its canonical byte form and +// either rewrites it (write) or reports drift (failing in strict mode). +func checkCanonicalForm(catalogPath string, data []byte, write, strict bool) error { + canonical, err := thresholds.CanonicalizeConsumerDriveProfileCatalog(data) + if err != nil { + return fmt.Errorf("canonicalize catalog: %w", err) + } + + if bytes.Equal(canonical, data) { + if write { + fmt.Println("OK: catalog already in canonical form") + } + return nil + } + + if write { + if writeErr := os.WriteFile(catalogPath, canonical, 0600); writeErr != nil { + return fmt.Errorf("write canonical catalog: %w", writeErr) + } + fmt.Printf("OK: rewrote %s in canonical form\n", catalogPath) + return nil + } + + fmt.Println("WARN: catalog is not in canonical form; run with -write to normalize it") + if strict { + return fmt.Errorf("catalog is not in canonical form") + } + return nil +} + +func runFixtures(handle *thresholds.ConsumerDriveCatalogHandle, fixturesPath string) error { + fixtureData, err := os.ReadFile(fixturesPath) + if err != nil { + return fmt.Errorf("read fixtures: %w", err) + } + failures, err := thresholds.CheckConsumerDriveProfileFixtures(handle, fixtureData) + if err != nil { + return err + } + for _, failure := range failures { + fmt.Fprintf(os.Stderr, "FIXTURE FAIL: %s\n", failure) + } + if len(failures) > 0 { + return fmt.Errorf("%d fixture failure(s)", len(failures)) + } + fmt.Println("OK: all expected-match fixtures pass") + return nil +} diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go index 50eefe522..2a9222a33 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_migrations.go +++ b/webapp/backend/pkg/database/scrutiny_repository_migrations.go @@ -609,6 +609,10 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error { return tx.Create(&defaultSettings).Error }, }, + { + ID: "m20260701000000", // add consumer drive profile family denylist setting (#552) + Migrate: sr.migrateM20260701000000, + }, }) if err := m.Migrate(); err != nil { @@ -1380,7 +1384,7 @@ func m20201107210306_FromPreInfluxDBSmartResultsCreatePostInfluxDBSmartResults(d } if preDevice.IsAta() { - postDeviceSmartData.ProcessAtaSmartInfo(nil, "", preDevice.ModelName, buildLegacyAtaAttributesTable(&preSmartResult)) + postDeviceSmartData.ProcessAtaSmartInfo(nil, "", preDevice.ModelName, preDevice.RotationSpeed == 0, buildLegacyAtaAttributesTable(&preSmartResult)) } else if preDevice.IsNvme() { postDeviceSmartData.ProcessNvmeSmartInfo(nil, buildLegacyNvmeHealthLog(&preSmartResult)) @@ -1540,3 +1544,22 @@ func buildLegacyScsiErrorLog(preSmartResult *m20201107210306.Smart) (int64, coll } return postScsiGrownDefectList, postScsiErrorCounterLog } + +// migrateM20260701000000 seeds the consumer drive profile family denylist setting (#552). +func (sr *scrutinyRepository) migrateM20260701000000(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&m20220716214900.Setting{}).Where("setting_key_name = ?", "metrics.consumer_drive_profiles_denylist").Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + defaultSetting := m20220716214900.Setting{ + SettingKeyName: "metrics.consumer_drive_profiles_denylist", + SettingKeyDescription: "Comma-separated consumer drive profile families excluded from profile matching (empty = none)", + SettingDataType: "string", + SettingValueString: "", + } + return tx.Create(&defaultSetting).Error +} diff --git a/webapp/backend/pkg/models/drive_profile_inspection.go b/webapp/backend/pkg/models/drive_profile_inspection.go new file mode 100644 index 000000000..eb806c748 --- /dev/null +++ b/webapp/backend/pkg/models/drive_profile_inspection.go @@ -0,0 +1,81 @@ +package models + +// DriveProfileInspection is the diagnostic view of consumer drive profile +// matching for a single device. It exposes the full override path: which +// catalog entry matched, how it matched, whether the confidence gate passed, +// which overrides would be applied, and why the drive fell back to generic +// ATA rules when no profile is in effect. +type DriveProfileInspection struct { + // DeviceWWN identifies the inspected drive. + DeviceWWN string `json:"device_wwn"` + + // DeviceProtocol is the device protocol (ATA, NVMe, SCSI). + DeviceProtocol string `json:"device_protocol"` + + // ModelFamily is the smartctl-reported model family used for matching. + ModelFamily string `json:"model_family,omitempty"` + + // ModelName is the model name used for matching. + ModelName string `json:"model_name,omitempty"` + + // CatalogVersion is the version of the bundled profile catalog. + CatalogVersion string `json:"catalog_version,omitempty"` + + // MatchMethod is the lookup stage that matched: "model_family", + // "model_name", "model_name_normalized", or "model_pattern". + MatchMethod string `json:"match_method,omitempty"` + + // MatchedValue is the input value (family or model name) that matched. + MatchedValue string `json:"matched_value,omitempty"` + + // ProfileFamily is the matched catalog family. + ProfileFamily string `json:"profile_family,omitempty"` + + // ProfileVendor is the vendor recorded on the matched profile. + ProfileVendor string `json:"profile_vendor,omitempty"` + + // ProfileSource is the provenance string of the matched profile. + ProfileSource string `json:"profile_source,omitempty"` + + // FallbackReason explains, in plain language, why generic ATA rules are in + // effect. Empty when a profile is applied. + FallbackReason string `json:"fallback_reason,omitempty"` + + // Denylist echoes the normalized family keys currently denylisted in settings. + Denylist []string `json:"denylist,omitempty"` + + // ObservedThresholdAttributes lists ATA attribute IDs whose observed-threshold + // buckets are overridden by the matched profile during SMART status evaluation. + ObservedThresholdAttributes []int `json:"observed_threshold_attributes,omitempty"` + + // CounterSeverityAttributes lists ATA attribute IDs whose counter severity + // breakpoints are overridden by the matched profile during replacement-risk scoring. + CounterSeverityAttributes []string `json:"counter_severity_attributes,omitempty"` + + // SampleCount is the sample size behind the matched profile. + SampleCount int `json:"sample_count,omitempty"` + + // MinSamples is the confidence gate the matched profile must satisfy. + MinSamples int `json:"min_samples,omitempty"` + + // ProfilesEnabled reports the global consumer drive profiles toggle. + ProfilesEnabled bool `json:"profiles_enabled"` + + // Matched reports whether the catalog recognized this drive at all, + // regardless of whether the profile was applied. + Matched bool `json:"matched"` + + // Applied reports whether the matched profile is actually used for SMART + // status evaluation and replacement-risk scoring. + Applied bool `json:"applied"` + + // ConfidenceMet reports whether the matched profile passes its confidence gate. + ConfidenceMet bool `json:"confidence_met"` +} + +// DriveProfileInspectionResponse is the API response envelope for the +// GET /api/device/:wwn/drive-profile endpoint. +type DriveProfileInspectionResponse struct { + Data DriveProfileInspection `json:"data"` + Success bool `json:"success"` +} diff --git a/webapp/backend/pkg/models/measurements/smart.go b/webapp/backend/pkg/models/measurements/smart.go index 4a773500a..d7a2611c5 100644 --- a/webapp/backend/pkg/models/measurements/smart.go +++ b/webapp/backend/pkg/models/measurements/smart.go @@ -236,7 +236,7 @@ func (sm *Smart) FromCollectorSmartInfoWithOverrides(cfg config.Interface, wwn s // process ATA/NVME/SCSI protocol data sm.Attributes = map[string]SmartAttribute{} if sm.DeviceProtocol == pkg.DeviceProtocolAta { - sm.processAtaSmartInfoWithOverrides(cfg, info.ModelFamily, info.ModelName, info.AtaSmartAttributes.Table, mergedOverrides) + sm.processAtaSmartInfoWithOverrides(cfg, info.ModelFamily, info.ModelName, isAtaSsdSmartInfo(&info), info.AtaSmartAttributes.Table, mergedOverrides) // Also process ATA Device Statistics (GP Log 0x04) for enterprise SSD metrics if len(info.AtaDeviceStatistics.Pages) > 0 { sm.processAtaDeviceStatisticsWithOverrides(cfg, info, mergedOverrides) @@ -255,11 +255,8 @@ func (sm *Smart) FromCollectorSmartInfoWithOverrides(cfg config.Interface, wwn s } // generate SmartAtaAttribute entries from Scrutiny Collector Smart data. -func (sm *Smart) ProcessAtaSmartInfo(cfg config.Interface, modelFamily string, modelName string, tableItems []collector.AtaSmartAttributesTableItem) { - var profile *thresholds.ConsumerDriveProfile - if consumerDriveProfilesEnabled(cfg) { - profile, _ = thresholds.LookupConsumerDriveProfile(pkg.DeviceProtocolAta, modelFamily, modelName) - } +func (sm *Smart) ProcessAtaSmartInfo(cfg config.Interface, modelFamily string, modelName string, isAtaSsd bool, tableItems []collector.AtaSmartAttributesTableItem) { + profile := consumerDriveProfileForDevice(cfg, modelFamily, modelName) for _, collectorAttr := range tableItems { attrModel := SmartAtaAttribute{ AttributeId: collectorAttr.ID, @@ -278,7 +275,7 @@ func (sm *Smart) ProcessAtaSmartInfo(cfg config.Interface, modelFamily string, m attrModel.TransformedValue = smartMetadata.Transform(attrModel.Value, attrModel.RawValue, attrModel.RawString) } } - attrModel.PopulateAttributeStatus(profile) + attrModel.PopulateAttributeStatus(profile, isAtaSsd) attrIdStr := strconv.Itoa(collectorAttr.ID) var ignored bool @@ -314,6 +311,10 @@ func isTransientAtaAttribute(cfg config.Interface, attrID int) bool { return false } +func isAtaSsdSmartInfo(info *collector.SmartInfo) bool { + return info != nil && info.Device.Protocol == pkg.DeviceProtocolAta && info.RotationRate == 0 +} + // isDevstatIgnored checks if an attribute ID is in the devstat ignore list func isDevstatIgnored(cfg config.Interface, attrId string) bool { if cfg == nil { @@ -474,11 +475,8 @@ func (sm *Smart) ProcessScsiSmartInfo(cfg config.Interface, defectGrownList int6 } // processAtaSmartInfoWithOverrides generates SmartAtaAttribute entries using pre-merged overrides. -func (sm *Smart) processAtaSmartInfoWithOverrides(cfg config.Interface, modelFamily string, modelName string, tableItems []collector.AtaSmartAttributesTableItem, mergedOverrides []overrides.AttributeOverride) { - var profile *thresholds.ConsumerDriveProfile - if consumerDriveProfilesEnabled(cfg) { - profile, _ = thresholds.LookupConsumerDriveProfile(pkg.DeviceProtocolAta, modelFamily, modelName) - } +func (sm *Smart) processAtaSmartInfoWithOverrides(cfg config.Interface, modelFamily string, modelName string, isAtaSsd bool, tableItems []collector.AtaSmartAttributesTableItem, mergedOverrides []overrides.AttributeOverride) { + profile := consumerDriveProfileForDevice(cfg, modelFamily, modelName) for _, collectorAttr := range tableItems { attrModel := SmartAtaAttribute{ AttributeId: collectorAttr.ID, @@ -497,7 +495,7 @@ func (sm *Smart) processAtaSmartInfoWithOverrides(cfg config.Interface, modelFam attrModel.TransformedValue = smartMetadata.Transform(attrModel.Value, attrModel.RawValue, attrModel.RawString) } } - attrModel.PopulateAttributeStatus(profile) + attrModel.PopulateAttributeStatus(profile, isAtaSsd) attrIdStr := strconv.Itoa(collectorAttr.ID) @@ -530,6 +528,24 @@ func consumerDriveProfilesEnabled(cfg config.Interface) bool { return cfg.GetBool(key) } +// consumerDriveProfileForDevice resolves the applied consumer drive profile for +// an ATA device, honoring the global toggle and the per-family denylist. +func consumerDriveProfileForDevice(cfg config.Interface, modelFamily string, modelName string) *thresholds.ConsumerDriveProfile { + if !consumerDriveProfilesEnabled(cfg) { + return nil + } + var denied map[string]struct{} + if cfg != nil { + denied = thresholds.ParseConsumerDriveProfileDenylist( + cfg.GetString(config.DB_USER_SETTINGS_SUBKEY + ".metrics.consumer_drive_profiles_denylist")) + } + match := thresholds.MatchConsumerDriveProfile(pkg.DeviceProtocolAta, modelFamily, modelName, denied) + if match == nil || !match.Applied { + return nil + } + return match.Profile +} + // processAtaDeviceStatisticsWithOverrides extracts device statistics using pre-merged overrides. func (sm *Smart) processAtaDeviceStatisticsWithOverrides(cfg config.Interface, deviceStatistics collector.SmartInfo, mergedOverrides []overrides.AttributeOverride) { for _, page := range deviceStatistics.AtaDeviceStatistics.Pages { diff --git a/webapp/backend/pkg/models/measurements/smart_ata_attribute.go b/webapp/backend/pkg/models/measurements/smart_ata_attribute.go index be08e741a..6d3ec3938 100644 --- a/webapp/backend/pkg/models/measurements/smart_ata_attribute.go +++ b/webapp/backend/pkg/models/measurements/smart_ata_attribute.go @@ -2,6 +2,7 @@ package measurements import ( "fmt" + "regexp" "strconv" "strings" @@ -9,6 +10,16 @@ import ( "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" ) +var ataAttributeNameTokenPattern = regexp.MustCompile(`[^a-z0-9]+`) + +var ataAttributeNameIgnoredTokens = map[string]struct{}{ + "ct": {}, + "count": {}, + "total": {}, +} + +const ataSsdVendorSpecificThresholdSkipReason = "Skipped generic ATA observed-threshold scoring for vendor-specific SSD attribute" + type SmartAtaAttribute struct { AttributeId int `json:"attribute_id"` Name string `json:"name,omitempty"` @@ -97,7 +108,7 @@ func (sa *SmartAtaAttribute) Inflate(key string, val interface{}) { // populate attribute status, using SMART Thresholds & Observed Metadata // Chainable -func (sa *SmartAtaAttribute) PopulateAttributeStatus(profile *thresholds.ConsumerDriveProfile) *SmartAtaAttribute { +func (sa *SmartAtaAttribute) PopulateAttributeStatus(profile *thresholds.ConsumerDriveProfile, isAtaSsd bool) *SmartAtaAttribute { if strings.ToUpper(sa.WhenFailed) == pkg.AttributeWhenFailedFailingNow { //this attribute has previously failed sa.Status = pkg.AttributeStatusSet(sa.Status, pkg.AttributeStatusFailedSmart) @@ -111,6 +122,10 @@ func (sa *SmartAtaAttribute) PopulateAttributeStatus(profile *thresholds.Consume } if smartMetadata, ok := thresholds.AtaMetadata[sa.AttributeId]; ok { + if sa.Status == pkg.AttributeStatusPassed && shouldSkipAtaSsdObservedThresholdScoring(isAtaSsd, &smartMetadata, sa.Name) { + sa.StatusReason = ataSsdVendorSpecificThresholdSkipReason + return sa + } if profile != nil { if profileThresholds, ok := profile.AtaObservedThresholds[sa.AttributeId]; ok && len(profileThresholds) > 0 { smartMetadata.ObservedThresholds = profileThresholds @@ -122,6 +137,63 @@ func (sa *SmartAtaAttribute) PopulateAttributeStatus(profile *thresholds.Consume return sa } +func shouldSkipAtaSsdObservedThresholdScoring(isAtaSsd bool, smartMetadata *thresholds.AtaAttributeMetadata, attributeName string) bool { + if !isAtaSsd || smartMetadata == nil || len(smartMetadata.ObservedThresholds) == 0 { + return false + } + return !ataAttributeNamesSemanticallyMatch(attributeName, smartMetadata.DisplayName) +} + +func ataAttributeNamesSemanticallyMatch(reportedName string, metadataName string) bool { + reportedTokens := ataAttributeNameTokens(reportedName) + metadataTokens := ataAttributeNameTokens(metadataName) + if len(reportedTokens) == 0 || len(metadataTokens) == 0 { + return false + } + + shorter := reportedTokens + longer := metadataTokens + if len(shorter) > len(longer) { + shorter, longer = longer, shorter + } + + return orderedSubsequence(shorter, longer) +} + +func ataAttributeNameTokens(name string) []string { + normalized := strings.ToLower(strings.TrimSpace(name)) + if normalized == "" { + return nil + } + + rawTokens := ataAttributeNameTokenPattern.Split(normalized, -1) + tokens := make([]string, 0, len(rawTokens)) + for _, token := range rawTokens { + if token == "" { + continue + } + if _, ignored := ataAttributeNameIgnoredTokens[token]; ignored { + continue + } + tokens = append(tokens, token) + } + return tokens +} + +func orderedSubsequence(shorter []string, longer []string) bool { + idx := 0 + for _, token := range longer { + if shorter[idx] != token { + continue + } + idx++ + if idx == len(shorter) { + return true + } + } + return false +} + // compare the attribute (raw, normalized, transformed) value to observed thresholds, and update status if necessary func (sa *SmartAtaAttribute) ValidateThreshold(smartMetadata thresholds.AtaAttributeMetadata) { // - if the attribute is critical diff --git a/webapp/backend/pkg/models/measurements/smart_ata_attribute_test.go b/webapp/backend/pkg/models/measurements/smart_ata_attribute_test.go index d0418c759..9e1252a47 100644 --- a/webapp/backend/pkg/models/measurements/smart_ata_attribute_test.go +++ b/webapp/backend/pkg/models/measurements/smart_ata_attribute_test.go @@ -238,7 +238,7 @@ func TestValidateThreshold_Attr177_AboveNormal_NormalizedValue110_Passes(t *test func TestValidateThreshold_Attr177_NoBucketWarning_Eliminated(t *testing.T) { sa := SmartAtaAttribute{AttributeId: 177, Value: 92, RawValue: 200} - sa.PopulateAttributeStatus(nil) + sa.PopulateAttributeStatus(nil, false) require.False(t, pkg.AttributeStatusHas(sa.Status, pkg.AttributeStatusWarningScrutiny)) require.False(t, pkg.AttributeStatusHas(sa.Status, pkg.AttributeStatusFailedScrutiny)) @@ -255,7 +255,7 @@ func TestPopulateAttributeStatus_UsesConsumerDriveProfileThresholds(t *testing.T }, } - sa.PopulateAttributeStatus(profile) + sa.PopulateAttributeStatus(profile, false) require.InDelta(t, 0.30, sa.FailureRate, 0.001) require.True(t, pkg.AttributeStatusHas(sa.Status, pkg.AttributeStatusFailedScrutiny)) @@ -264,8 +264,30 @@ func TestPopulateAttributeStatus_UsesConsumerDriveProfileThresholds(t *testing.T func TestPopulateAttributeStatus_FallsBackWithoutProfile(t *testing.T) { sa := SmartAtaAttribute{AttributeId: 5, RawValue: 40} - sa.PopulateAttributeStatus(nil) + sa.PopulateAttributeStatus(nil, false) require.InDelta(t, 0.23589260654405794, sa.FailureRate, 0.001) require.True(t, pkg.AttributeStatusHas(sa.Status, pkg.AttributeStatusFailedScrutiny)) } + +func TestAtaAttributeNamesSemanticallyMatch(t *testing.T) { + require.True(t, ataAttributeNamesSemanticallyMatch("High_Fly_Writes", "High Fly Writes")) + require.True(t, ataAttributeNamesSemanticallyMatch("Runtime_Bad_Block", "SATA Downshift Error Count or Runtime Bad Block")) + require.True(t, ataAttributeNamesSemanticallyMatch("Factory_Bad_Block_Ct", "Factory Bad Block Count")) + require.False(t, ataAttributeNamesSemanticallyMatch("Factory_Bad_Block_Ct", "High Fly Writes")) +} + +func TestPopulateAttributeStatus_SkipsGenericObservedThresholdsForVendorSpecificAtaSsd(t *testing.T) { + sa := SmartAtaAttribute{ + AttributeId: 189, + Name: "Factory_Bad_Block_Ct", + Value: 100, + RawValue: 81, + } + + sa.PopulateAttributeStatus(nil, true) + + require.Equal(t, pkg.AttributeStatusPassed, sa.Status) + require.Equal(t, ataSsdVendorSpecificThresholdSkipReason, sa.StatusReason) + require.Zero(t, sa.FailureRate) +} diff --git a/webapp/backend/pkg/models/measurements/smart_test.go b/webapp/backend/pkg/models/measurements/smart_test.go index 8d51108ae..24e88b3e1 100644 --- a/webapp/backend/pkg/models/measurements/smart_test.go +++ b/webapp/backend/pkg/models/measurements/smart_test.go @@ -18,6 +18,7 @@ import ( func expectConsumerDriveProfilesEnabledDefault(fakeConfig *mock_config.MockInterface) { fakeConfig.EXPECT().IsSet("user.metrics.consumer_drive_profiles_enabled").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("user.metrics.consumer_drive_profiles_denylist").Return("").AnyTimes() } func TestSmart_Flatten(t *testing.T) { @@ -462,6 +463,108 @@ func TestFromCollectorSmartInfo_Fail_ScrutinyNonCriticalFailed(t *testing.T) { require.Equal(t, 14, len(smartMdl.Attributes)) } +func TestFromCollectorSmartInfo_ATA_Issue587VendorSpecificSsdAttribute(t *testing.T) { + //setup + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + fakeConfig := mock_config.NewMockInterface(mockCtrl) + expectConsumerDriveProfilesEnabledDefault(fakeConfig) + fakeConfig.EXPECT().GetIntSlice("failures.transient.ata").Return([]int{195}).AnyTimes() + fakeConfig.EXPECT().Get("smart.attribute_overrides").Return(nil).AnyTimes() + + smartDataFile, err := os.Open("../testdata/smart-ata-issue587-realssd-m4.json") + require.NoError(t, err) + defer smartDataFile.Close() + + var smartJson collector.SmartInfo + + smartDataBytes, err := ioutil.ReadAll(smartDataFile) + require.NoError(t, err) + err = json.Unmarshal(smartDataBytes, &smartJson) + require.NoError(t, err) + + //test + smartMdl := measurements.Smart{} + err = smartMdl.FromCollectorSmartInfo(fakeConfig, "WWN-test", smartJson) + + //assert + require.NoError(t, err) + require.Equal(t, pkg.DeviceStatusPassed, smartMdl.Status) + require.Equal(t, int64(0), smartMdl.Temp) + + attr189 := smartMdl.Attributes["189"].(*measurements.SmartAtaAttribute) + require.Equal(t, pkg.AttributeStatusPassed, attr189.Status) + require.Equal(t, "Skipped generic ATA observed-threshold scoring for vendor-specific SSD attribute", attr189.StatusReason) + require.Zero(t, attr189.FailureRate) +} + +func TestFromCollectorSmartInfo_ATA_HddHighFlyWritesStillUsesGenericScoring(t *testing.T) { + //setup + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + fakeConfig := mock_config.NewMockInterface(mockCtrl) + expectConsumerDriveProfilesEnabledDefault(fakeConfig) + fakeConfig.EXPECT().GetIntSlice("failures.transient.ata").Return([]int{195}).AnyTimes() + fakeConfig.EXPECT().Get("smart.attribute_overrides").Return(nil).AnyTimes() + + smartDataFile, err := os.Open("../testdata/smart-sat.json") + require.NoError(t, err) + defer smartDataFile.Close() + + var smartJson collector.SmartInfo + + smartDataBytes, err := ioutil.ReadAll(smartDataFile) + require.NoError(t, err) + err = json.Unmarshal(smartDataBytes, &smartJson) + require.NoError(t, err) + smartJson.RotationRate = 7200 + + //test + smartMdl := measurements.Smart{} + err = smartMdl.FromCollectorSmartInfo(fakeConfig, "WWN-test", smartJson) + + //assert + require.NoError(t, err) + + attr189 := smartMdl.Attributes["189"].(*measurements.SmartAtaAttribute) + require.Equal(t, pkg.AttributeStatusPassed, attr189.Status) + require.Empty(t, attr189.StatusReason) + require.Greater(t, attr189.FailureRate, 0.0) +} + +func TestFromCollectorSmartInfo_ATA_SsdRuntimeBadBlockStillUsesGenericScoring(t *testing.T) { + //setup + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + fakeConfig := mock_config.NewMockInterface(mockCtrl) + expectConsumerDriveProfilesEnabledDefault(fakeConfig) + fakeConfig.EXPECT().GetIntSlice("failures.transient.ata").Return([]int{195}).AnyTimes() + fakeConfig.EXPECT().Get("smart.attribute_overrides").Return(nil).AnyTimes() + + smartDataFile, err := os.Open("../testdata/smart-ata-failed-scrutiny.json") + require.NoError(t, err) + defer smartDataFile.Close() + + var smartJson collector.SmartInfo + + smartDataBytes, err := ioutil.ReadAll(smartDataFile) + require.NoError(t, err) + err = json.Unmarshal(smartDataBytes, &smartJson) + require.NoError(t, err) + + //test + smartMdl := measurements.Smart{} + err = smartMdl.FromCollectorSmartInfo(fakeConfig, "WWN-test", smartJson) + + //assert + require.NoError(t, err) + + attr183 := smartMdl.Attributes["183"].(*measurements.SmartAtaAttribute) + require.Equal(t, pkg.AttributeStatusPassed, attr183.Status) + require.Empty(t, attr183.StatusReason) + require.Greater(t, attr183.FailureRate, 0.0) +} + //TODO: Scrutiny Warn //TODO: Smart + Scrutiny Warn diff --git a/webapp/backend/pkg/models/replacement_risk.go b/webapp/backend/pkg/models/replacement_risk.go index a795bfca0..17daa470d 100644 --- a/webapp/backend/pkg/models/replacement_risk.go +++ b/webapp/backend/pkg/models/replacement_risk.go @@ -115,6 +115,23 @@ type ReplacementRiskScore struct { // ConsumerDriveProfileFamily is the matched profile family when ConsumerDriveProfileApplied is true. ConsumerDriveProfileFamily string `json:"consumer_drive_profile_family,omitempty"` + + // ConsumerDriveProfileSource is the provenance string of the applied profile + // (curated dataset description). Omitted when generic ATA logic is used. + ConsumerDriveProfileSource string `json:"consumer_drive_profile_source,omitempty"` + + // ConsumerDriveProfileSampleCount is the sample size behind the applied profile. + // Omitted when generic ATA logic is used. + ConsumerDriveProfileSampleCount int `json:"consumer_drive_profile_sample_count,omitempty"` + + // ConsumerDriveProfileMatchMethod reports which lookup stage matched the drive: + // "model_family", "model_name", "model_name_normalized", or "model_pattern". + // Omitted when generic ATA logic is used. + ConsumerDriveProfileMatchMethod string `json:"consumer_drive_profile_match_method,omitempty"` + + // ConsumerDriveProfileCatalogVersion is the bundled catalog version the applied + // profile came from. Omitted when generic ATA logic is used. + ConsumerDriveProfileCatalogVersion string `json:"consumer_drive_profile_catalog_version,omitempty"` } // ReplacementRiskResponse is the API response envelope for the diff --git a/webapp/backend/pkg/models/settings.go b/webapp/backend/pkg/models/settings.go index fa11bcb99..ce2f0d211 100644 --- a/webapp/backend/pkg/models/settings.go +++ b/webapp/backend/pkg/models/settings.go @@ -28,6 +28,7 @@ type Settings struct { ReportWeeklyDay int `json:"report_weekly_day" mapstructure:"report_weekly_day"` MissedPingTimeoutMinutes int `json:"missed_ping_timeout_minutes" mapstructure:"missed_ping_timeout_minutes"` UptimeKumaIntervalSeconds int `json:"uptime_kuma_interval_seconds" mapstructure:"uptime_kuma_interval_seconds"` + ConsumerDriveProfilesDenylist string `json:"consumer_drive_profiles_denylist" mapstructure:"consumer_drive_profiles_denylist"` ReportEnabled bool `json:"report_enabled" mapstructure:"report_enabled"` ReportDailyEnabled bool `json:"report_daily_enabled" mapstructure:"report_daily_enabled"` ConsumerDriveProfilesEnabled bool `json:"consumer_drive_profiles_enabled" mapstructure:"consumer_drive_profiles_enabled"` diff --git a/webapp/backend/pkg/models/testdata/smart-ata-issue587-realssd-m4.json b/webapp/backend/pkg/models/testdata/smart-ata-issue587-realssd-m4.json new file mode 100644 index 000000000..e68593b21 --- /dev/null +++ b/webapp/backend/pkg/models/testdata/smart-ata-issue587-realssd-m4.json @@ -0,0 +1,2394 @@ +{ + "json_format_version": [ + 1, + 0 + ], + "smartctl": { + "version": [ + 7, + 4 + ], + "pre_release": false, + "svn_revision": "5530", + "platform_info": "x86_64-linux-6.12.91-production+truenas", + "build_info": "(local build)", + "argv": [ + "smartctl", + "--xall", + "--json", + "/dev/sdd" + ], + "drive_database_version": { + "string": "7.3/5528" + }, + "exit_status": 0 + }, + "local_time": { + "time_t": 1783265471, + "asctime": "Sun Jul 5 17:31:11 2026 CEST" + }, + "device": { + "name": "/dev/sdd", + "info_name": "/dev/sdd [SAT]", + "type": "sat", + "protocol": "ATA" + }, + "model_family": "Crucial/Micron RealSSD m4/C400/P400", + "model_name": "MTFDDAK128MAM-1J1", + "serial_number": "123009116906", + "wwn": { + "naa": 5, + "oui": 41077, + "id": 4447103238 + }, + "firmware_version": "0809", + "user_capacity": { + "blocks": 250069680, + "bytes": 128035676160 + }, + "logical_block_size": 512, + "physical_block_size": 512, + "rotation_rate": 0, + "form_factor": { + "ata_value": 3, + "name": "2.5 inches" + }, + "trim": { + "supported": true, + "deterministic": true, + "zeroed": true + }, + "in_smartctl_database": true, + "ata_version": { + "string": "ACS-2, ATA8-ACS T13/1699-D revision 6", + "major_value": 1016, + "minor_value": 40 + }, + "sata_version": { + "string": "SATA 3.0", + "value": 63 + }, + "interface_speed": { + "max": { + "sata_value": 14, + "string": "6.0 Gb/s", + "units_per_second": 60, + "bits_per_unit": 100000000 + }, + "current": { + "sata_value": 3, + "string": "6.0 Gb/s", + "units_per_second": 60, + "bits_per_unit": 100000000 + } + }, + "smart_support": { + "available": true, + "enabled": true + }, + "ata_apm": { + "enabled": false + }, + "read_lookahead": { + "enabled": true + }, + "write_cache": { + "enabled": true + }, + "ata_security": { + "state": 32801, + "string": "Disabled, NOT FROZEN [SEC1]", + "enabled": false, + "frozen": false, + "master_password_id": 65534 + }, + "smart_status": { + "passed": true + }, + "ata_smart_data": { + "offline_data_collection": { + "status": { + "value": 0, + "string": "was never started" + }, + "completion_seconds": 595 + }, + "self_test": { + "status": { + "value": 0, + "string": "completed without error", + "passed": true + }, + "polling_minutes": { + "short": 2, + "extended": 9, + "conveyance": 3 + } + }, + "capabilities": { + "values": [ + 123, + 3 + ], + "exec_offline_immediate_supported": true, + "offline_is_aborted_upon_new_cmd": false, + "offline_surface_scan_supported": true, + "self_tests_supported": true, + "conveyance_self_test_supported": true, + "selective_self_test_supported": true, + "attribute_autosave_enabled": true, + "error_logging_supported": true, + "gp_logging_supported": true + } + }, + "ata_sct_capabilities": { + "value": 61, + "error_recovery_control_supported": true, + "feature_control_supported": true, + "data_table_supported": true + }, + "ata_smart_attributes": { + "revision": 16, + "table": [ + { + "id": 1, + "name": "Raw_Read_Error_Rate", + "value": 100, + "worst": 100, + "thresh": 50, + "when_failed": "", + "flags": { + "value": 47, + "string": "POSR-K ", + "prefailure": true, + "updated_online": true, + "performance": true, + "error_rate": true, + "event_count": false, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 5, + "name": "Reallocated_Sector_Ct", + "value": 100, + "worst": 100, + "thresh": 10, + "when_failed": "", + "flags": { + "value": 51, + "string": "PO--CK ", + "prefailure": true, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 9, + "name": "Power_On_Hours", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 4179, + "string": "4179" + } + }, + { + "id": 12, + "name": "Power_Cycle_Count", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 1931, + "string": "1931" + } + }, + { + "id": 170, + "name": "Grown_Failing_Block_Ct", + "value": 100, + "worst": 100, + "thresh": 10, + "when_failed": "", + "flags": { + "value": 51, + "string": "PO--CK ", + "prefailure": true, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 171, + "name": "Program_Fail_Count", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 172, + "name": "Erase_Fail_Count", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 130, + "string": "130" + } + }, + { + "id": 173, + "name": "Wear_Leveling_Count", + "value": 98, + "worst": 98, + "thresh": 10, + "when_failed": "", + "flags": { + "value": 51, + "string": "PO--CK ", + "prefailure": true, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 68, + "string": "68" + } + }, + { + "id": 174, + "name": "Unexpect_Power_Loss_Ct", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 1, + "string": "1" + } + }, + { + "id": 181, + "name": "Non4k_Aligned_Access", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 34, + "string": "-O---K ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": false, + "auto_keep": true + }, + "raw": { + "value": 8306523505707, + "string": "1934 866 1067" + } + }, + { + "id": 183, + "name": "SATA_Iface_Downshift", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 184, + "name": "End-to-End_Error", + "value": 100, + "worst": 100, + "thresh": 50, + "when_failed": "", + "flags": { + "value": 51, + "string": "PO--CK ", + "prefailure": true, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 187, + "name": "Reported_Uncorrect", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 188, + "name": "Command_Timeout", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 189, + "name": "Factory_Bad_Block_Ct", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 14, + "string": "-OSR-- ", + "prefailure": false, + "updated_online": true, + "performance": true, + "error_rate": true, + "event_count": false, + "auto_keep": false + }, + "raw": { + "value": 81, + "string": "81" + } + }, + { + "id": 194, + "name": "Temperature_Celsius", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 34, + "string": "-O---K ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": false, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 195, + "name": "Hardware_ECC_Recovered", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 58, + "string": "-O-RCK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": true, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 196, + "name": "Reallocated_Event_Count", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 197, + "name": "Current_Pending_Sector", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 50, + "string": "-O--CK ", + "prefailure": false, + "updated_online": true, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 198, + "name": "Offline_Uncorrectable", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 48, + "string": "----CK ", + "prefailure": false, + "updated_online": false, + "performance": false, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 199, + "name": "UDMA_CRC_Error_Count", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 54, + "string": "-OS-CK ", + "prefailure": false, + "updated_online": true, + "performance": true, + "error_rate": false, + "event_count": true, + "auto_keep": true + }, + "raw": { + "value": 0, + "string": "0" + } + }, + { + "id": 202, + "name": "Perc_Rated_Life_Used", + "value": 98, + "worst": 98, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 24, + "string": "---RC- ", + "prefailure": false, + "updated_online": false, + "performance": false, + "error_rate": true, + "event_count": true, + "auto_keep": false + }, + "raw": { + "value": 2, + "string": "2" + } + }, + { + "id": 206, + "name": "Write_Error_Rate", + "value": 100, + "worst": 100, + "thresh": 0, + "when_failed": "", + "flags": { + "value": 14, + "string": "-OSR-- ", + "prefailure": false, + "updated_online": true, + "performance": true, + "error_rate": true, + "event_count": false, + "auto_keep": false + }, + "raw": { + "value": 0, + "string": "0" + } + } + ] + }, + "power_on_time": { + "hours": 4179 + }, + "power_cycle_count": 1931, + "ata_log_directory": { + "gp_dir_version": 1, + "smart_dir_version": 1, + "smart_dir_multi_sector": true, + "table": [ + { + "address": 0, + "name": "Log Directory", + "read": true, + "write": false, + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 1, + "name": "Summary SMART error log", + "read": true, + "write": false, + "smart_sectors": 1 + }, + { + "address": 2, + "name": "Comprehensive SMART error log", + "read": true, + "write": false, + "smart_sectors": 51 + }, + { + "address": 3, + "name": "Ext. Comprehensive SMART error log", + "read": true, + "write": false, + "gp_sectors": 16383 + }, + { + "address": 4, + "name": "Device Statistics log", + "read": true, + "write": false, + "gp_sectors": 255, + "smart_sectors": 255 + }, + { + "address": 6, + "name": "SMART self-test log", + "read": true, + "write": false, + "smart_sectors": 1 + }, + { + "address": 7, + "name": "Extended self-test log", + "read": true, + "write": false, + "gp_sectors": 3449 + }, + { + "address": 9, + "name": "Selective self-test log", + "read": true, + "write": true, + "smart_sectors": 1 + }, + { + "address": 16, + "name": "NCQ Command Error log", + "read": true, + "write": false, + "gp_sectors": 1 + }, + { + "address": 17, + "name": "SATA Phy Event Counters log", + "read": true, + "write": false, + "gp_sectors": 1 + }, + { + "address": 128, + "name": "Host vendor specific log", + "read": true, + "write": true, + "smart_sectors": 1 + }, + { + "address": 129, + "name": "Host vendor specific log", + "read": true, + "write": true, + "smart_sectors": 1 + }, + { + "address": 130, + "name": "Host vendor specific log", + "read": true, + "write": true, + "smart_sectors": 1 + }, + { + "address": 131, + "name": "Host vendor specific log", + "read": true, + "write": true, + "smart_sectors": 1 + }, + { + "address": 132, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 133, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 134, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 135, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 136, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 137, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 138, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 139, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 140, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 141, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 142, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 143, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 144, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 145, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 146, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 147, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 148, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 149, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 150, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 151, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 152, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 153, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 154, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 155, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 156, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 157, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 158, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 159, + "name": "Host vendor specific log", + "read": true, + "write": true, + "gp_sectors": 16, + "smart_sectors": 16 + }, + { + "address": 160, + "name": "Device vendor specific log", + "gp_sectors": 2000, + "smart_sectors": 208 + }, + { + "address": 161, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 162, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 163, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 164, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 165, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 166, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 167, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 168, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 169, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 170, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 171, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 172, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 173, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 174, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 175, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 176, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 177, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 178, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 179, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 180, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 181, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 182, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 183, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 184, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 185, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 186, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 187, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 188, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 189, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 190, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 191, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 192, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 193, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 194, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 195, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 196, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 197, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 198, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 199, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 200, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 201, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 202, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 203, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 204, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 205, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 206, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 207, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 208, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 209, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 210, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 211, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 212, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 213, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 214, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 215, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 216, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 217, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 218, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 219, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 220, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 221, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 222, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 223, + "name": "Device vendor specific log", + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 224, + "name": "SCT Command/Status", + "read": true, + "write": true, + "gp_sectors": 1, + "smart_sectors": 1 + }, + { + "address": 225, + "name": "SCT Data Transfer", + "read": true, + "write": true, + "gp_sectors": 1, + "smart_sectors": 1 + } + ] + }, + "ata_smart_error_log": { + "extended": { + "revision": 1, + "sectors": 16383, + "count": 0 + } + }, + "ata_smart_self_test_log": { + "standard": { + "revision": 1, + "count": 0 + } + }, + "ata_smart_selective_self_test_log": { + "revision": 1, + "table": [ + { + "lba_min": 0, + "lba_max": 0, + "status": { + "value": 0, + "string": "Not_testing" + } + }, + { + "lba_min": 0, + "lba_max": 0, + "status": { + "value": 0, + "string": "Not_testing" + } + }, + { + "lba_min": 0, + "lba_max": 0, + "status": { + "value": 0, + "string": "Not_testing" + } + }, + { + "lba_min": 0, + "lba_max": 0, + "status": { + "value": 0, + "string": "Not_testing" + } + }, + { + "lba_min": 0, + "lba_max": 0, + "status": { + "value": 0, + "string": "Not_testing" + } + } + ], + "flags": { + "value": 0, + "remainder_scan_enabled": false + }, + "power_up_scan_resume_minutes": 0 + }, + "ata_sct_status": { + "format_version": 3, + "sct_version": 1, + "device_state": { + "value": 0, + "string": "Active" + }, + "temperature": { + "current": 0, + "power_cycle_max": 0, + "lifetime_max": 0 + } + }, + "temperature": { + "current": 0, + "power_cycle_max": 0, + "lifetime_max": 0, + "op_limit_min": 0, + "op_limit_max": 70, + "limit_min": -5, + "limit_max": 75, + "lifetime_min": 0 + }, + "ata_sct_temperature_history": { + "version": 2, + "sampling_period_minutes": 10, + "logging_interval_minutes": 10, + "temperature": { + "op_limit_min": 0, + "op_limit_max": 70, + "limit_min": -5, + "limit_max": 75 + }, + "size": 478, + "index": 23, + "table": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + 0, + 0 + ] + }, + "ata_sct_erc": { + "read": { + "enabled": true, + "deciseconds": 40 + }, + "write": { + "enabled": true, + "deciseconds": 40 + } + }, + "ata_device_statistics": { + "pages": [ + { + "number": 1, + "name": "General Statistics", + "revision": 2, + "table": [ + { + "offset": 8, + "name": "Lifetime Power-On Resets", + "size": 4, + "value": 1931, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 16, + "name": "Power-on Hours", + "size": 4, + "value": 4179, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 24, + "name": "Logical Sectors Written", + "size": 6, + "value": 13843916035, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 32, + "name": "Number of Write Commands", + "size": 6, + "value": 297320424, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 40, + "name": "Logical Sectors Read", + "size": 6, + "value": 20683670158, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 48, + "name": "Number of Read Commands", + "size": 6, + "value": 285738347, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + } + ] + }, + { + "number": 4, + "name": "General Errors Statistics", + "revision": 1, + "table": [ + { + "offset": 8, + "name": "Number of Reported Uncorrectable Errors", + "size": 4, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 16, + "name": "Resets Between Cmd Acceptance and Completion", + "size": 4, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + } + ] + }, + { + "number": 5, + "name": "Temperature Statistics", + "revision": 1, + "table": [ + { + "offset": 8, + "name": "Current Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 16, + "name": "Average Short Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 24, + "name": "Average Long Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 32, + "name": "Highest Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 40, + "name": "Lowest Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 48, + "name": "Highest Average Short Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 56, + "name": "Lowest Average Short Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 64, + "name": "Highest Average Long Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 72, + "name": "Lowest Average Long Term Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 80, + "name": "Time in Over-Temperature", + "size": 4, + "flags": { + "value": 128, + "string": "---- ", + "valid": false, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 88, + "name": "Specified Maximum Operating Temperature", + "size": 1, + "value": 70, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 96, + "name": "Time in Under-Temperature", + "size": 4, + "flags": { + "value": 128, + "string": "---- ", + "valid": false, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 104, + "name": "Specified Minimum Operating Temperature", + "size": 1, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + } + ] + }, + { + "number": 6, + "name": "Transport Statistics", + "revision": 1, + "table": [ + { + "offset": 8, + "name": "Number of Hardware Resets", + "size": 4, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 16, + "name": "Number of ASR Events", + "size": 4, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + }, + { + "offset": 24, + "name": "Number of Interface CRC Errors", + "size": 4, + "value": 0, + "flags": { + "value": 192, + "string": "V--- ", + "valid": true, + "normalized": false, + "supports_dsn": false, + "monitored_condition_met": false + } + } + ] + }, + { + "number": 7, + "name": "Solid State Device Statistics", + "revision": 1, + "table": [ + { + "offset": 8, + "name": "Percentage Used Endurance Indicator", + "size": 1, + "value": 3, + "flags": { + "value": 224, + "string": "VN-- ", + "valid": true, + "normalized": true, + "supports_dsn": false, + "monitored_condition_met": false + } + } + ] + } + ] + }, + "sata_phy_event_counters": { + "table": [ + { + "id": 1, + "name": "Command failed due to ICRC error", + "size": 4, + "value": 0, + "overflow": false + }, + { + "id": 10, + "name": "Device-to-host register FISes sent due to a COMRESET", + "size": 4, + "value": 1, + "overflow": false + } + ], + "reset": false + }, + "seagate_farm_log": { + "supported": false + } +} diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles.go b/webapp/backend/pkg/thresholds/consumer_drive_profiles.go index 66bad9c94..6385e5656 100644 --- a/webapp/backend/pkg/thresholds/consumer_drive_profiles.go +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles.go @@ -10,6 +10,33 @@ import ( const MinConsumerDriveProfileSamples = 20 +// ProfileMatchMethod identifies which lookup stage produced a consumer drive +// profile match. Stages are listed strongest-first: an exact model_family hit +// is more trustworthy than a regex pattern fallback. +type ProfileMatchMethod string + +const ( + // ProfileMatchMethodModelFamily is an exact match on the smartctl-reported model family. + ProfileMatchMethodModelFamily ProfileMatchMethod = "model_family" + + // ProfileMatchMethodModelName is an exact match on the normalized model name (alias table). + ProfileMatchMethodModelName ProfileMatchMethod = "model_name" + + // ProfileMatchMethodModelNameNormalized is an exact match after vendor-aware + // normalization (capacity suffix or firmware suffix stripped). + ProfileMatchMethodModelNameNormalized ProfileMatchMethod = "model_name_normalized" + + // ProfileMatchMethodModelPattern is a regex model_pattern fallback match. + ProfileMatchMethodModelPattern ProfileMatchMethod = "model_pattern" +) + +// Skip reasons reported on a ConsumerDriveProfileMatch that was found in the +// catalog but intentionally not applied. +const ( + ProfileSkipReasonBelowConfidence = "below_confidence_threshold" + ProfileSkipReasonFamilyDenylisted = "family_denylisted" +) + type CounterSeverityProfile struct { Low int64 `json:"low"` Moderate int64 `json:"moderate"` @@ -32,10 +59,34 @@ type ConsumerDriveProfile struct { } type consumerDriveProfileCatalog struct { + Version string `json:"version,omitempty"` Aliases map[string]string `json:"aliases"` Profiles []ConsumerDriveProfile `json:"profiles"` } +// ConsumerDriveProfileMatch describes the outcome of matching a device against +// the bundled profile catalog. A non-nil match means the catalog recognized the +// drive; Applied reports whether the profile was actually used (confidence gate +// passed and family not denylisted). +type ConsumerDriveProfileMatch struct { + Profile *ConsumerDriveProfile + + // Method identifies the lookup stage that produced this match. + Method ProfileMatchMethod + + // MatchedValue is the input value (family or model name) that matched. + MatchedValue string + + // SkipReason explains why a matched profile was not applied. Empty when Applied. + SkipReason string + + // CatalogVersion is the version string of the bundled catalog. + CatalogVersion string + + // Applied reports whether the profile should be used for scoring. + Applied bool +} + func (p *ConsumerDriveProfile) MeetsConfidenceThreshold() bool { minSamples := p.MinSamples if minSamples <= 0 { @@ -44,58 +95,68 @@ func (p *ConsumerDriveProfile) MeetsConfidenceThreshold() bool { return p.SampleCount >= minSamples } +// EffectiveMinSamples returns the confidence gate this profile must satisfy. +func (p *ConsumerDriveProfile) EffectiveMinSamples() int { + if p.MinSamples > 0 { + return p.MinSamples + } + return MinConsumerDriveProfileSamples +} + //go:embed consumer_drive_profiles.json var consumerDriveProfilesJSON []byte -var ( - consumerDriveProfilesByFamily map[string]ConsumerDriveProfile - consumerDriveProfilesByModel map[string]ConsumerDriveProfile - consumerDriveProfilesByRegex []ConsumerDriveProfile -) +type loadedConsumerDriveCatalog struct { + version string + byFamily map[string]ConsumerDriveProfile + byModel map[string]ConsumerDriveProfile + byRegex []ConsumerDriveProfile +} + +var consumerDriveCatalog *loadedConsumerDriveCatalog func init() { - if err := loadConsumerDriveProfiles(); err != nil { + catalog, err := parseConsumerDriveProfiles(consumerDriveProfilesJSON) + if err != nil { panic(err) } + consumerDriveCatalog = catalog } -func ValidateConsumerDriveProfileCatalog(data []byte) error { - _, _, _, err := parseConsumerDriveProfiles(data) - return err +// ConsumerDriveProfileCatalogVersion returns the version string of the bundled catalog. +func ConsumerDriveProfileCatalogVersion() string { + return consumerDriveCatalog.version } -func loadConsumerDriveProfiles() error { - byFamily, byModel, regexProfiles, err := parseConsumerDriveProfiles(consumerDriveProfilesJSON) - if err != nil { - return err - } - consumerDriveProfilesByFamily = byFamily - consumerDriveProfilesByModel = byModel - consumerDriveProfilesByRegex = regexProfiles - return nil +func ValidateConsumerDriveProfileCatalog(data []byte) error { + _, err := parseConsumerDriveProfiles(data) + return err } -func parseConsumerDriveProfiles(data []byte) (map[string]ConsumerDriveProfile, map[string]ConsumerDriveProfile, []ConsumerDriveProfile, error) { +func parseConsumerDriveProfiles(data []byte) (*loadedConsumerDriveCatalog, error) { var catalog consumerDriveProfileCatalog if err := json.Unmarshal(data, &catalog); err != nil { - return nil, nil, nil, fmt.Errorf("unmarshal consumer drive profiles: %w", err) + return nil, fmt.Errorf("unmarshal consumer drive profiles: %w", err) } - byFamily := make(map[string]ConsumerDriveProfile, len(catalog.Profiles)) - regexProfiles := make([]ConsumerDriveProfile, 0) + loaded := &loadedConsumerDriveCatalog{ + version: catalog.Version, + byFamily: make(map[string]ConsumerDriveProfile, len(catalog.Profiles)), + byModel: make(map[string]ConsumerDriveProfile, len(catalog.Aliases)), + byRegex: make([]ConsumerDriveProfile, 0), + } for i := range catalog.Profiles { - if err := addConsumerDriveProfile(&catalog.Profiles[i], byFamily, ®exProfiles); err != nil { - return nil, nil, nil, err + if err := addConsumerDriveProfile(&catalog.Profiles[i], loaded.byFamily, &loaded.byRegex); err != nil { + return nil, err } } - byModel := make(map[string]ConsumerDriveProfile, len(catalog.Aliases)) for modelAlias, family := range catalog.Aliases { - if err := addConsumerDriveAlias(modelAlias, family, byFamily, byModel); err != nil { - return nil, nil, nil, err + if err := addConsumerDriveAlias(modelAlias, family, loaded.byFamily, loaded.byModel); err != nil { + return nil, err } } - return byFamily, byModel, regexProfiles, nil + return loaded, nil } // addConsumerDriveProfile validates a single profile and registers it under its normalized family @@ -104,6 +165,18 @@ func addConsumerDriveProfile(profile *ConsumerDriveProfile, byFamily map[string] if profile.ModelFamily == "" { return fmt.Errorf("profile missing model_family") } + if !strings.EqualFold(profile.Protocol, "ATA") { + return fmt.Errorf("profile %q has unsupported protocol %q (only ATA is supported)", profile.ModelFamily, profile.Protocol) + } + if strings.TrimSpace(profile.Source) == "" { + return fmt.Errorf("profile %q missing source", profile.ModelFamily) + } + if profile.SampleCount <= 0 { + return fmt.Errorf("profile %q has non-positive sample_count %d", profile.ModelFamily, profile.SampleCount) + } + if profile.MinSamples < 0 { + return fmt.Errorf("profile %q has negative min_samples %d", profile.ModelFamily, profile.MinSamples) + } familyKey := normalizeConsumerDriveKey(profile.ModelFamily) if familyKey == "" { return fmt.Errorf("profile has empty normalized family for %q", profile.ModelFamily) @@ -111,6 +184,9 @@ func addConsumerDriveProfile(profile *ConsumerDriveProfile, byFamily map[string] if _, exists := byFamily[familyKey]; exists { return fmt.Errorf("duplicate profile family %q", profile.ModelFamily) } + if err := validateConsumerDriveOverrides(profile); err != nil { + return err + } if profile.ModelPattern != "" { compiled, err := regexp.Compile(profile.ModelPattern) if err != nil { @@ -123,6 +199,42 @@ func addConsumerDriveProfile(profile *ConsumerDriveProfile, byFamily map[string] return nil } +// validateConsumerDriveOverrides applies structural sanity rules to a profile's +// severity overrides and observed-threshold buckets. +func validateConsumerDriveOverrides(profile *ConsumerDriveProfile) error { + for attrID, sev := range profile.AtaCounterSeverityOverrides { + if sev.Low < 0 || sev.Moderate < sev.Low || sev.High < sev.Moderate || sev.Critical < sev.High { + return fmt.Errorf("profile %q attribute %s severity override violates low <= moderate <= high <= critical ordering", profile.ModelFamily, attrID) + } + } + for attrID, buckets := range profile.AtaObservedThresholds { + for _, bucket := range buckets { + if err := validateObservedThresholdBucket(bucket); err != nil { + return fmt.Errorf("profile %q attribute %d: %w", profile.ModelFamily, attrID, err) + } + } + } + return nil +} + +// validateObservedThresholdBucket checks a single observed-threshold bucket for +// structural validity. +func validateObservedThresholdBucket(bucket ObservedThreshold) error { + if bucket.Low > bucket.High { + return fmt.Errorf("observed threshold bucket has low > high") + } + if bucket.AnnualFailureRate < 0 || bucket.AnnualFailureRate > 1 { + return fmt.Errorf("annual_failure_rate outside [0, 1]") + } + if len(bucket.ErrorInterval) != 0 && len(bucket.ErrorInterval) != 2 { + return fmt.Errorf("error_interval must have exactly 2 values") + } + if len(bucket.ErrorInterval) == 2 && bucket.ErrorInterval[0] > bucket.ErrorInterval[1] { + return fmt.Errorf("error_interval is not ordered") + } + return nil +} + // addConsumerDriveAlias resolves an alias to its family profile and registers it under the // normalized model key, tolerating duplicate aliases that point to the same family. func addConsumerDriveAlias(modelAlias, family string, byFamily, byModel map[string]ConsumerDriveProfile) error { @@ -144,24 +256,189 @@ func addConsumerDriveAlias(modelAlias, family string, byFamily, byModel map[stri return nil } -func LookupConsumerDriveProfile(protocol, modelFamily, modelName string) (*ConsumerDriveProfile, bool) { +// ParseConsumerDriveProfileDenylist converts a comma-separated list of family +// names into a set of normalized family keys. Empty entries are ignored. +func ParseConsumerDriveProfileDenylist(csv string) map[string]struct{} { + if strings.TrimSpace(csv) == "" { + return nil + } + denied := map[string]struct{}{} + for _, entry := range strings.Split(csv, ",") { + key := normalizeConsumerDriveKey(entry) + if key == "" { + continue + } + denied[key] = struct{}{} + } + if len(denied) == 0 { + return nil + } + return denied +} + +// consumerDriveProfileCandidate is an intermediate raw catalog hit before +// confidence gating and denylist filtering. +type consumerDriveProfileCandidate struct { + profile *ConsumerDriveProfile + method ProfileMatchMethod + matchedValue string +} + +// MatchConsumerDriveProfile matches a device against the bundled profile +// catalog and reports full match metadata. deniedFamilies contains normalized +// family keys (see ParseConsumerDriveProfileDenylist) that must not be applied. +// +// Lookup stages run strongest-first: exact model_family, exact model_name +// (alias table), vendor-normalized model_name, then regex model_pattern. +// A candidate that fails the confidence gate or is denylisted is skipped and +// weaker stages are still consulted. When no candidate can be applied, the +// first skipped candidate is returned (Applied=false) so callers can explain +// why the drive fell back to generic ATA rules. Returns nil when the catalog +// does not recognize the drive at all. +func MatchConsumerDriveProfile(protocol, modelFamily, modelName string, deniedFamilies map[string]struct{}) *ConsumerDriveProfileMatch { + return consumerDriveCatalog.match(protocol, modelFamily, modelName, deniedFamilies) +} + +// ConsumerDriveCatalogHandle wraps a parsed catalog document so tooling (the +// catalog-lint CLI, tests) can run matches against a catalog file on disk +// instead of the embedded bundle. +type ConsumerDriveCatalogHandle struct { + loaded *loadedConsumerDriveCatalog +} + +// LoadConsumerDriveProfileCatalog parses and validates a catalog document and +// returns a handle for running matches against it. +func LoadConsumerDriveProfileCatalog(data []byte) (*ConsumerDriveCatalogHandle, error) { + loaded, err := parseConsumerDriveProfiles(data) + if err != nil { + return nil, err + } + return &ConsumerDriveCatalogHandle{loaded: loaded}, nil +} + +// Match runs the same matching pipeline as MatchConsumerDriveProfile against +// this handle's catalog. +func (h *ConsumerDriveCatalogHandle) Match(protocol, modelFamily, modelName string, deniedFamilies map[string]struct{}) *ConsumerDriveProfileMatch { + return h.loaded.match(protocol, modelFamily, modelName, deniedFamilies) +} + +// Version returns the catalog document's version string. +func (h *ConsumerDriveCatalogHandle) Version() string { + return h.loaded.version +} + +func (lc *loadedConsumerDriveCatalog) match(protocol, modelFamily, modelName string, deniedFamilies map[string]struct{}) *ConsumerDriveProfileMatch { if !strings.EqualFold(protocol, "ATA") { - return nil, false + return nil + } + + var firstSkipped *ConsumerDriveProfileMatch + for _, candidate := range lc.candidates(modelFamily, modelName) { + match := &ConsumerDriveProfileMatch{ + Profile: candidate.profile, + Method: candidate.method, + MatchedValue: candidate.matchedValue, + CatalogVersion: lc.version, + } + if _, denied := deniedFamilies[normalizeConsumerDriveKey(candidate.profile.ModelFamily)]; denied { + match.SkipReason = ProfileSkipReasonFamilyDenylisted + } else if !candidate.profile.MeetsConfidenceThreshold() { + match.SkipReason = ProfileSkipReasonBelowConfidence + } else { + match.Applied = true + return match + } + if firstSkipped == nil { + firstSkipped = match + } } + return firstSkipped +} - if profile, ok := consumerDriveProfilesByFamily[normalizeConsumerDriveKey(modelFamily)]; ok && profile.MeetsConfidenceThreshold() { - return &profile, true +// candidates returns raw catalog hits ordered strongest-first. +func (lc *loadedConsumerDriveCatalog) candidates(modelFamily, modelName string) []consumerDriveProfileCandidate { + candidates := make([]consumerDriveProfileCandidate, 0, 2) + + if profile, ok := lc.byFamily[normalizeConsumerDriveKey(modelFamily)]; ok { + candidates = append(candidates, consumerDriveProfileCandidate{&profile, ProfileMatchMethodModelFamily, modelFamily}) + } + if profile, ok := lc.byModel[normalizeConsumerDriveKey(modelName)]; ok { + candidates = append(candidates, consumerDriveProfileCandidate{&profile, ProfileMatchMethodModelName, modelName}) } - if profile, ok := consumerDriveProfilesByModel[normalizeConsumerDriveKey(modelName)]; ok && profile.MeetsConfidenceThreshold() { - return &profile, true + for _, key := range consumerDriveModelNameVariants(modelName) { + if profile, ok := lc.byModel[key]; ok { + candidates = append(candidates, consumerDriveProfileCandidate{&profile, ProfileMatchMethodModelNameNormalized, modelName}) + continue + } + if profile, ok := lc.byFamily[key]; ok { + candidates = append(candidates, consumerDriveProfileCandidate{&profile, ProfileMatchMethodModelNameNormalized, modelName}) + } } - for i := range consumerDriveProfilesByRegex { - profile := &consumerDriveProfilesByRegex[i] - if profile.compiledPattern != nil && profile.compiledPattern.MatchString(modelName) && profile.MeetsConfidenceThreshold() { - return profile, true + for i := range lc.byRegex { + profile := lc.byRegex[i] + if profile.compiledPattern != nil && profile.compiledPattern.MatchString(modelName) { + candidates = append(candidates, consumerDriveProfileCandidate{&profile, ProfileMatchMethodModelPattern, modelName}) } } - return nil, false + return candidates +} + +// LookupConsumerDriveProfile returns the applied profile for a device, if any. +// It is a convenience wrapper around MatchConsumerDriveProfile without denylist +// filtering; callers that need match metadata or denylist support should use +// MatchConsumerDriveProfile directly. +func LookupConsumerDriveProfile(protocol, modelFamily, modelName string) (*ConsumerDriveProfile, bool) { + match := MatchConsumerDriveProfile(protocol, modelFamily, modelName, nil) + if match == nil || !match.Applied { + return nil, false + } + return match.Profile, true +} + +var ( + // capacity suffix: " 500GB", "_1TB", "-2.5TB" at end of model name + consumerDriveCapacitySuffixPattern = regexp.MustCompile(`(?i)[\s_-]+\d+(\.\d+)?\s?(gb|tb)$`) + // WDC firmware suffix: "WDC WD80EFAX-68LHPN0" -> "WDC WD80EFAX" + consumerDriveWdcFirmwarePattern = regexp.MustCompile(`(?i)^(wdc[\s_]+wd[0-9a-z]+)-[0-9a-z]{6,10}$`) + // Seagate firmware suffix: "ST4000DM000-1F2168" -> "ST4000DM000" + consumerDriveSeagateFirmwarePattern = regexp.MustCompile(`(?i)^(st\d+[a-z]{2}\d{3}[a-z]?)-[0-9a-z]+$`) +) + +// consumerDriveModelNameVariants generates vendor-aware normalized lookup keys +// for a raw model name. Each variant strips one well-known decoration (capacity +// suffix, vendor firmware suffix) so real-world model strings can hit exact +// alias or family entries without resorting to broad regex patterns. +func consumerDriveModelNameVariants(modelName string) []string { + trimmed := strings.TrimSpace(modelName) + if trimmed == "" { + return nil + } + base := normalizeConsumerDriveKey(trimmed) + + variants := make([]string, 0, 2) + appendVariant := func(candidate string) { + key := normalizeConsumerDriveKey(candidate) + if key == "" || key == base { + return + } + for _, existing := range variants { + if existing == key { + return + } + } + variants = append(variants, key) + } + + if stripped := consumerDriveCapacitySuffixPattern.ReplaceAllString(trimmed, ""); stripped != trimmed { + appendVariant(stripped) + } + if m := consumerDriveWdcFirmwarePattern.FindStringSubmatch(trimmed); m != nil { + appendVariant(m[1]) + } + if m := consumerDriveSeagateFirmwarePattern.FindStringSubmatch(trimmed); m != nil { + appendVariant(m[1]) + } + return variants } func normalizeConsumerDriveKey(value string) string { diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles.json b/webapp/backend/pkg/thresholds/consumer_drive_profiles.json index ec0bcbddd..c0e62742c 100644 --- a/webapp/backend/pkg/thresholds/consumer_drive_profiles.json +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles.json @@ -1,584 +1,1621 @@ { + "version": "2026.07.0", "profiles": [ { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung based SSDs", - "sample_count": 200, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "196": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "197": { "low": 0, "moderate": 1, "high": 4, "critical": 12 }, - "198": { "low": 0, "moderate": 1, "high": 4, "critical": 12 } - } + "196": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + } + }, + "sample_count": 200 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Hitachi", "model_family": "Hitachi Deskstar 7K1000.D", - "sample_count": 41, "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.02, "error_interval": [0.01, 0.03] }, - { "low": 1, "high": 8, "annual_failure_rate": 0.06, "error_interval": [0.03, 0.1] }, - { "low": 8, "high": 32, "annual_failure_rate": 0.18, "error_interval": [0.1, 0.27] }, - { "low": 32, "high": 128, "annual_failure_rate": 0.35, "error_interval": [0.22, 0.49] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.02, + "error_interval": [ + 0.01, + 0.03 + ] + }, + { + "low": 1, + "high": 8, + "annual_failure_rate": 0.06, + "error_interval": [ + 0.03, + 0.1 + ] + }, + { + "low": 8, + "high": 32, + "annual_failure_rate": 0.18, + "error_interval": [ + 0.1, + 0.27 + ] + }, + { + "low": 32, + "high": 128, + "annual_failure_rate": 0.35, + "error_interval": [ + 0.22, + 0.49 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 16, "critical": 48 }, - "196": { "low": 0, "moderate": 4, "high": 16, "critical": 48 }, - "197": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "198": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "10": { "low": 0, "moderate": 1, "high": 3, "critical": 8 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "196": { + "low": 0, + "moderate": 4, + "high": 16, + "critical": 48 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 16, + "critical": 48 + } + }, + "sample_count": 41 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Red", - "sample_count": 44, - "model_pattern": "^(WDC[_ ]WD(80EFAX|60EFRX|30EFRX).*)$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.02, "error_interval": [0.01, 0.03] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.05, "error_interval": [0.03, 0.08] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.14, "error_interval": [0.09, 0.2] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.28, "error_interval": [0.19, 0.38] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.02, + "error_interval": [ + 0.01, + 0.03 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.05, + "error_interval": [ + 0.03, + 0.08 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.14, + "error_interval": [ + 0.09, + 0.2 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.28, + "error_interval": [ + 0.19, + 0.38 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "196": { "low": 0, "moderate": 2, "high": 8, "critical": 24 }, - "197": { "low": 0, "moderate": 1, "high": 4, "critical": 12 }, - "198": { "low": 0, "moderate": 1, "high": 4, "critical": 12 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 6 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 6 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 24 + } + }, + "model_pattern": "^(WDC[_ ]WD(80EFAX|60EFRX|30EFRX).*)$", + "sample_count": 44 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Red Plus", - "sample_count": 147, - "model_pattern": "^(WDC[_ ]WD140EDFZ.*)$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.01, "error_interval": [0.005, 0.02] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.03, "error_interval": [0.02, 0.05] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.09, "error_interval": [0.06, 0.13] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.18, "error_interval": [0.12, 0.25] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.01, + "error_interval": [ + 0.005, + 0.02 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.03, + "error_interval": [ + 0.02, + 0.05 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.09, + "error_interval": [ + 0.06, + 0.13 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.18, + "error_interval": [ + 0.12, + 0.25 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "10": { "low": 0, "moderate": 1, "high": 3, "critical": 8 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "model_pattern": "^(WDC[_ ]WD140EDFZ.*)$", + "sample_count": 147 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Desktop SSHD", - "sample_count": 322, - "model_pattern": "^ST6000DX000-1H217Z$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.03, "error_interval": [0.02, 0.05] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.08, "error_interval": [0.05, 0.11] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.17, "error_interval": [0.12, 0.23] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.33, "error_interval": [0.24, 0.43] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.03, + "error_interval": [ + 0.02, + 0.05 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.08, + "error_interval": [ + 0.05, + 0.11 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.17, + "error_interval": [ + 0.12, + 0.23 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.33, + "error_interval": [ + 0.24, + 0.43 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 6, "critical": 18 }, - "196": { "low": 0, "moderate": 1, "high": 6, "critical": 18 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 10 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 10 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 5 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 5 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 6, + "critical": 18 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 10 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 10 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 6, + "critical": 18 + } + }, + "model_pattern": "^ST6000DX000-1H217Z$", + "sample_count": 322 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda 7200.14 (AF)", - "sample_count": 6657, - "model_pattern": "^ST4000DM000-.*$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.04, "error_interval": [0.03, 0.05] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.1, "error_interval": [0.08, 0.13] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.22, "error_interval": [0.18, 0.27] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.4, "error_interval": [0.33, 0.48] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.04, + "error_interval": [ + 0.03, + 0.05 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.1, + "error_interval": [ + 0.08, + 0.13 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.22, + "error_interval": [ + 0.18, + 0.27 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.4, + "error_interval": [ + 0.33, + 0.48 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 12 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 12 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 5 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 5 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 12 + } + }, + "model_pattern": "^ST4000DM000-.*$", + "sample_count": 6657 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Caviar Blue", - "sample_count": 305, - "model_pattern": "^WD10EZEX-.*$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.03, "error_interval": [0.02, 0.04] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.07, "error_interval": [0.05, 0.1] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.16, "error_interval": [0.12, 0.21] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.31, "error_interval": [0.24, 0.39] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.03, + "error_interval": [ + 0.02, + 0.04 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.07, + "error_interval": [ + 0.05, + 0.1 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.16, + "error_interval": [ + 0.12, + 0.21 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.31, + "error_interval": [ + 0.24, + 0.39 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 8, "critical": 20 }, - "196": { "low": 0, "moderate": 2, "high": 8, "critical": 20 }, - "197": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "198": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 5 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 5 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 20 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 8, + "critical": 20 + } + }, + "model_pattern": "^WD10EZEX-.*$", + "sample_count": 305 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Caviar Green", - "sample_count": 150, - "model_pattern": "^WD20EARS-.*$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.04, "error_interval": [0.03, 0.05] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.09, "error_interval": [0.06, 0.12] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.2, "error_interval": [0.15, 0.26] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.38, "error_interval": [0.29, 0.48] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.04, + "error_interval": [ + 0.03, + 0.05 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.09, + "error_interval": [ + 0.06, + 0.12 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.2, + "error_interval": [ + 0.15, + 0.26 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.38, + "error_interval": [ + 0.29, + 0.48 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 6, "critical": 18 }, - "196": { "low": 0, "moderate": 1, "high": 6, "critical": 18 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 5 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 5 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 6, + "critical": 18 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 6, + "critical": 18 + } + }, + "model_pattern": "^WD20EARS-.*$", + "sample_count": 150 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda 7200.12", - "sample_count": 3071, - "model_pattern": "^ST2000DM001-.*$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.05, "error_interval": [0.04, 0.06] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.12, "error_interval": [0.1, 0.15] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.25, "error_interval": [0.21, 0.3] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.45, "error_interval": [0.38, 0.53] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.05, + "error_interval": [ + 0.04, + 0.06 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.12, + "error_interval": [ + 0.1, + 0.15 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.25, + "error_interval": [ + 0.21, + 0.3 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.45, + "error_interval": [ + 0.38, + 0.53 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "model_pattern": "^ST2000DM001-.*$", + "sample_count": 3071 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Desktop HDD.15", - "sample_count": 320, - "model_pattern": "^ST3000DM001-.*$", "ata_observed_thresholds": { "5": [ - { "low": 0, "high": 0, "annual_failure_rate": 0.05, "error_interval": [0.04, 0.07] }, - { "low": 1, "high": 4, "annual_failure_rate": 0.13, "error_interval": [0.1, 0.17] }, - { "low": 4, "high": 16, "annual_failure_rate": 0.27, "error_interval": [0.21, 0.34] }, - { "low": 16, "high": 64, "annual_failure_rate": 0.46, "error_interval": [0.36, 0.56] } + { + "low": 0, + "high": 0, + "annual_failure_rate": 0.05, + "error_interval": [ + 0.04, + 0.07 + ] + }, + { + "low": 1, + "high": 4, + "annual_failure_rate": 0.13, + "error_interval": [ + 0.1, + 0.17 + ] + }, + { + "low": 4, + "high": 16, + "annual_failure_rate": 0.27, + "error_interval": [ + 0.21, + 0.34 + ] + }, + { + "low": 16, + "high": 64, + "annual_failure_rate": 0.46, + "error_interval": [ + 0.36, + 0.56 + ] + } ] }, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "model_pattern": "^ST3000DM001-.*$", + "sample_count": 320 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 850 PRO", - "sample_count": 355, - "model_pattern": "^(X SSD 850 PRO 128GB|Samsung SSD 850 PRO.*)$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 } - } + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "model_pattern": "^(X SSD 850 PRO 128GB|Samsung SSD 850 PRO.*)$", + "sample_count": 355 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 850 EVO", - "sample_count": 1184, - "model_pattern": "^Samsung SSD 850 EVO.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 } - } + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "model_pattern": "^Samsung SSD 850 EVO.*$", + "sample_count": 1184 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 870 EVO", - "sample_count": 897, - "model_pattern": "^Samsung SSD 870 EVO.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^Samsung SSD 870 EVO.*$", + "sample_count": 897 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 860 PRO", - "sample_count": 246, - "model_pattern": "^Samsung SSD 860 PRO.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^Samsung SSD 860 PRO.*$", + "sample_count": 246 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 870 QVO", - "sample_count": 254, - "model_pattern": "^Samsung SSD 870 QVO.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^Samsung SSD 870 QVO.*$", + "sample_count": 254 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Crucial", "model_family": "Crucial MX500", - "sample_count": 2214, - "model_pattern": "^CT(250|500|1000|2000|4000)MX500SSD1.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^CT(250|500|1000|2000|4000)MX500SSD1.*$", + "sample_count": 2214 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Crucial", "model_family": "Crucial MX300", - "sample_count": 426, - "model_pattern": "^CT(275|525|750|1050)MX300SSD1.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^CT(275|525|750|1050)MX300SSD1.*$", + "sample_count": 426 + }, + { + "protocol": "ATA", + "source": "scrutiny ATA SSD compatibility profile", + "vendor": "Crucial", + "model_family": "Crucial/Micron RealSSD m4/C400/P400", + "ata_observed_thresholds": { + "189": [ + { + "low": 0, + "high": 0, + "annual_failure_rate": 0, + "error_interval": null + }, + { + "low": 1, + "high": 9223372036854775807, + "annual_failure_rate": 0, + "error_interval": null + } + ] + }, + "sample_count": 20 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Crucial", "model_family": "Crucial BX500", - "sample_count": 481, - "model_pattern": "^CT(120|240|480|500|1000|2000|4000)BX500SSD1.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^CT(120|240|480|500|1000|2000|4000)BX500SSD1.*$", + "sample_count": 481 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "SanDisk", "model_family": "SanDisk Ultra II", - "sample_count": 214, - "model_pattern": "^SanDisk SDSSDHII-.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 } - } + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "model_pattern": "^SanDisk SDSSDHII-.*$", + "sample_count": 214 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "SanDisk", "model_family": "SanDisk 3D SSD", - "sample_count": 163, - "model_pattern": "^SanDisk SDSSDA-.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 } - } + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "model_pattern": "^SanDisk SDSSDA-.*$", + "sample_count": 163 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Intel", "model_family": "Intel SSD 545s Series", - "sample_count": 171, - "model_pattern": "^INTEL SSDSC2KW(128|256|512)G8.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^INTEL SSDSC2KW(128|256|512)G8.*$", + "sample_count": 171 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Scorpio Blue", - "sample_count": 222, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "196": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + } + }, + "sample_count": 222 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Blue Mobile", - "sample_count": 140, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "196": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + } + }, + "sample_count": 140 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda 7200.10", - "sample_count": 2156, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "sample_count": 2156 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda 7200.9", - "sample_count": 756, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "sample_count": 756 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 840 EVO", - "sample_count": 445, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "196": { "low": 0, "moderate": 3, "high": 10, "critical": 28 }, - "197": { "low": 0, "moderate": 2, "high": 6, "critical": 16 }, - "198": { "low": 0, "moderate": 2, "high": 6, "critical": 16 } - } + "196": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + }, + "197": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "198": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 16 + }, + "5": { + "low": 0, + "moderate": 3, + "high": 10, + "critical": 28 + } + }, + "sample_count": 445 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 860 EVO", - "sample_count": 1717, - "model_pattern": "^Samsung SSD 860 EVO( [0-9].*|$)", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^Samsung SSD 860 EVO( [0-9].*|$)", + "sample_count": 1717 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer SSD profile", "vendor": "Samsung", "model_family": "Samsung SSD 860 EVO M.2", - "sample_count": 173, - "model_pattern": "^Samsung SSD 860 EVO M\\.2.*$", "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "196": { "low": 0, "moderate": 4, "high": 12, "critical": 32 }, - "197": { "low": 0, "moderate": 3, "high": 8, "critical": 20 }, - "198": { "low": 0, "moderate": 3, "high": 8, "critical": 20 } - } + "196": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + }, + "197": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "198": { + "low": 0, + "moderate": 3, + "high": 8, + "critical": 20 + }, + "5": { + "low": 0, + "moderate": 4, + "high": 12, + "critical": 32 + } + }, + "model_pattern": "^Samsung SSD 860 EVO M\\.2.*$", + "sample_count": 173 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Scorpio Black", - "sample_count": 52, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 6, "critical": 14 }, - "196": { "low": 0, "moderate": 2, "high": 6, "critical": 14 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 14 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 14 + } + }, + "sample_count": 52 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "WDC", "model_family": "WDC Black Mobile", - "sample_count": 31, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 2, "high": 6, "critical": 14 }, - "196": { "low": 0, "moderate": 2, "high": 6, "critical": 14 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 8 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 14 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 8 + }, + "5": { + "low": 0, + "moderate": 2, + "high": 6, + "critical": 14 + } + }, + "sample_count": 31 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda Green (AF)", - "sample_count": 447, "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "sample_count": 447 }, { "protocol": "ATA", "source": "linuxhw/SMART curated consumer HDD profile", "vendor": "Seagate", "model_family": "Seagate Barracuda LP", - "sample_count": 167, - "ata_counter_severity_overrides": { - "5": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "196": { "low": 0, "moderate": 1, "high": 4, "critical": 10 }, - "197": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "198": { "low": 0, "moderate": 1, "high": 3, "critical": 6 }, - "10": { "low": 0, "moderate": 1, "high": 2, "critical": 4 } - } + "ata_counter_severity_overrides": { + "10": { + "low": 0, + "moderate": 1, + "high": 2, + "critical": 4 + }, + "196": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + }, + "197": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "198": { + "low": 0, + "moderate": 1, + "high": 3, + "critical": 6 + }, + "5": { + "low": 0, + "moderate": 1, + "high": 4, + "critical": 10 + } + }, + "sample_count": 167 } ], "aliases": { - "Samsung SSD 860 EVO 500GB": "Samsung SSD 860 EVO", - "Samsung SSD 860 EVO 250GB": "Samsung SSD 860 EVO", - "Samsung SSD 860 EVO 1TB": "Samsung SSD 860 EVO", - "Samsung SSD 860 EVO 2TB": "Samsung SSD 860 EVO", - "Samsung SSD 860 EVO M.2 500GB": "Samsung SSD 860 EVO M.2", - "Samsung SSD 860 EVO M.2 1TB": "Samsung SSD 860 EVO M.2", - "Samsung SSD 860 EVO M.2 2TB": "Samsung SSD 860 EVO M.2", - "Samsung SSD 840 Series": "Samsung based SSDs", + "CT1000BX500SSD1": "Crucial BX500", + "CT1000MX500SSD1": "Crucial MX500", + "CT1050MX300SSD1": "Crucial MX300", + "CT500BX500SSD1": "Crucial BX500", + "CT500MX500SSD1": "Crucial MX500", + "CT525MX300SSD1": "Crucial MX300", "Hitachi HDS721050DLE630": "Hitachi Deskstar 7K1000.D", - "WDC WD80EFAX-68LHPN0": "WDC Red", - "WDC_WD80EFAX-68LHPN0": "WDC Red", - "WDC WD60EFRX-68MYMN1": "WDC Red", - "WDC_WD60EFRX-68MYMN1": "WDC Red", - "WDC WD30EFRX-68AX9N0": "WDC Red", - "WDC_WD140EDFZ-11A0VA0": "WDC Red Plus", - "WDC WD140EDFZ-11A0VA0": "WDC Red Plus", - "ST6000DX000-1H217Z": "Seagate Desktop SSHD", - "ST4000DM000-1CD168": "Seagate Barracuda 7200.14 (AF)", - "ST4000DM000-1F2168": "Seagate Barracuda 7200.14 (AF)", - "WD10EZEX-08WN4A0": "WDC Caviar Blue", - "WD10EZEX-00BN5A0": "WDC Caviar Blue", - "WD20EARS-00MVWB0": "WDC Caviar Green", - "ST2000DM001-1ER164": "Seagate Barracuda 7200.12", + "INTEL SSDSC2KW256G8": "Intel SSD 545s Series", + "INTEL SSDSC2KW512G8": "Intel SSD 545s Series", "ST2000DM001-1CH164": "Seagate Barracuda 7200.12", - "ST3000DM001-1ER166": "Seagate Desktop HDD.15", + "ST2000DM001-1ER164": "Seagate Barracuda 7200.12", "ST3000DM001-1CH166": "Seagate Desktop HDD.15", - "X SSD 850 PRO 128GB": "Samsung SSD 850 PRO", + "ST3000DM001-1ER166": "Seagate Desktop HDD.15", + "ST4000DM000-1CD168": "Seagate Barracuda 7200.14 (AF)", + "ST4000DM000-1F2168": "Seagate Barracuda 7200.14 (AF)", + "ST6000DX000-1H217Z": "Seagate Desktop SSHD", + "Samsung SSD 840 EVO 120GB": "Samsung SSD 840 EVO", + "Samsung SSD 840 EVO 1TB": "Samsung SSD 840 EVO", + "Samsung SSD 840 EVO 250GB": "Samsung SSD 840 EVO", + "Samsung SSD 840 EVO 500GB": "Samsung SSD 840 EVO", + "Samsung SSD 840 Series": "Samsung based SSDs", + "Samsung SSD 850 EVO 1TB": "Samsung SSD 850 EVO", "Samsung SSD 850 EVO 250GB": "Samsung SSD 850 EVO", "Samsung SSD 850 EVO 500GB": "Samsung SSD 850 EVO", - "Samsung SSD 850 EVO 1TB": "Samsung SSD 850 EVO", + "Samsung SSD 860 EVO 1TB": "Samsung SSD 860 EVO", + "Samsung SSD 860 EVO 250GB": "Samsung SSD 860 EVO", + "Samsung SSD 860 EVO 2TB": "Samsung SSD 860 EVO", + "Samsung SSD 860 EVO 500GB": "Samsung SSD 860 EVO", + "Samsung SSD 860 EVO M.2 1TB": "Samsung SSD 860 EVO M.2", + "Samsung SSD 860 EVO M.2 2TB": "Samsung SSD 860 EVO M.2", + "Samsung SSD 860 EVO M.2 500GB": "Samsung SSD 860 EVO M.2", + "Samsung SSD 860 PRO 1TB": "Samsung SSD 860 PRO", + "Samsung SSD 860 PRO 512GB": "Samsung SSD 860 PRO", "Samsung SSD 870 EVO 1TB": "Samsung SSD 870 EVO", "Samsung SSD 870 EVO 500GB": "Samsung SSD 870 EVO", - "Samsung SSD 860 PRO 512GB": "Samsung SSD 860 PRO", - "Samsung SSD 860 PRO 1TB": "Samsung SSD 860 PRO", "Samsung SSD 870 QVO 1TB": "Samsung SSD 870 QVO", "Samsung SSD 870 QVO 2TB": "Samsung SSD 870 QVO", - "CT500MX500SSD1": "Crucial MX500", - "CT1000MX500SSD1": "Crucial MX500", - "CT525MX300SSD1": "Crucial MX300", - "CT1050MX300SSD1": "Crucial MX300", - "CT500BX500SSD1": "Crucial BX500", - "CT1000BX500SSD1": "Crucial BX500", - "SanDisk SDSSDHII-480G": "SanDisk Ultra II", - "SanDisk SDSSDHII-240G": "SanDisk Ultra II", - "SanDisk SDSSDA-500G": "SanDisk 3D SSD", "SanDisk SDSSDA-1T00-G26": "SanDisk 3D SSD", - "INTEL SSDSC2KW256G8": "Intel SSD 545s Series", - "INTEL SSDSC2KW512G8": "Intel SSD 545s Series", - "Samsung SSD 840 EVO 500GB": "Samsung SSD 840 EVO", - "Samsung SSD 840 EVO 250GB": "Samsung SSD 840 EVO", - "Samsung SSD 840 EVO 1TB": "Samsung SSD 840 EVO", - "Samsung SSD 840 EVO 120GB": "Samsung SSD 840 EVO" + "SanDisk SDSSDA-500G": "SanDisk 3D SSD", + "SanDisk SDSSDHII-240G": "SanDisk Ultra II", + "SanDisk SDSSDHII-480G": "SanDisk Ultra II", + "WD10EZEX-00BN5A0": "WDC Caviar Blue", + "WD10EZEX-08WN4A0": "WDC Caviar Blue", + "WD20EARS-00MVWB0": "WDC Caviar Green", + "WDC WD140EDFZ-11A0VA0": "WDC Red Plus", + "WDC WD30EFRX-68AX9N0": "WDC Red", + "WDC WD60EFRX-68MYMN1": "WDC Red", + "WDC WD80EFAX-68LHPN0": "WDC Red", + "WDC_WD140EDFZ-11A0VA0": "WDC Red Plus", + "WDC_WD60EFRX-68MYMN1": "WDC Red", + "WDC_WD80EFAX-68LHPN0": "WDC Red", + "X SSD 850 PRO 128GB": "Samsung SSD 850 PRO" } } diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles_fixtures.go b/webapp/backend/pkg/thresholds/consumer_drive_profiles_fixtures.go new file mode 100644 index 000000000..08a76af99 --- /dev/null +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles_fixtures.go @@ -0,0 +1,109 @@ +package thresholds + +import ( + "encoding/json" + "fmt" +) + +// ConsumerDriveProfileFixture is one expected-match regression case for the +// profile catalog. Fixtures pin the behavior of representative real-world +// model strings so catalog edits that unintentionally change matches fail fast. +type ConsumerDriveProfileFixture struct { + Description string `json:"description,omitempty"` + + // Inputs, as reported by smartctl. + Protocol string `json:"protocol"` + ModelFamily string `json:"model_family,omitempty"` + ModelName string `json:"model_name,omitempty"` + + // ExpectFamily is the catalog family the drive must resolve to (when matched). + ExpectFamily string `json:"expect_family,omitempty"` + + // ExpectMethod is the required match method (when matched). + ExpectMethod string `json:"expect_method,omitempty"` + + // ExpectMatched is true when the catalog should recognize the drive. + ExpectMatched bool `json:"expect_matched"` + + // ExpectApplied is true when the matched profile should actually be used. + ExpectApplied bool `json:"expect_applied"` +} + +// CheckConsumerDriveProfileFixtures runs every fixture against the catalog and +// returns a list of human-readable failures. An empty list means all fixtures +// pass. The error return covers malformed fixture documents only. +func CheckConsumerDriveProfileFixtures(handle *ConsumerDriveCatalogHandle, fixtureData []byte) ([]string, error) { + var fixtures []ConsumerDriveProfileFixture + if err := json.Unmarshal(fixtureData, &fixtures); err != nil { + return nil, fmt.Errorf("unmarshal fixtures: %w", err) + } + + var failures []string + for i, fixture := range fixtures { + label := fixture.Description + if label == "" { + label = fmt.Sprintf("fixture #%d (family=%q model=%q)", i, fixture.ModelFamily, fixture.ModelName) + } + failures = append(failures, checkConsumerDriveProfileFixture(handle, &fixtures[i], label)...) + } + return failures, nil +} + +func checkConsumerDriveProfileFixture(handle *ConsumerDriveCatalogHandle, fixture *ConsumerDriveProfileFixture, label string) []string { + match := handle.Match(fixture.Protocol, fixture.ModelFamily, fixture.ModelName, nil) + + if !fixture.ExpectMatched { + if match != nil { + return []string{fmt.Sprintf("%s: expected no catalog match, got family %q via %s", label, match.Profile.ModelFamily, match.Method)} + } + return nil + } + + if match == nil { + return []string{fmt.Sprintf("%s: expected a catalog match, got none", label)} + } + + var failures []string + if fixture.ExpectFamily != "" && match.Profile.ModelFamily != fixture.ExpectFamily { + failures = append(failures, fmt.Sprintf("%s: expected family %q, got %q", label, fixture.ExpectFamily, match.Profile.ModelFamily)) + } + if fixture.ExpectMethod != "" && string(match.Method) != fixture.ExpectMethod { + failures = append(failures, fmt.Sprintf("%s: expected match method %q, got %q", label, fixture.ExpectMethod, match.Method)) + } + if match.Applied != fixture.ExpectApplied { + failures = append(failures, fmt.Sprintf("%s: expected applied=%t, got applied=%t (skip reason %q)", label, fixture.ExpectApplied, match.Applied, match.SkipReason)) + } + return failures +} + +// CanonicalizeConsumerDriveProfileCatalog re-emits a catalog document in the +// canonical bundled format: 2-space indentation, version first, profiles in +// declared order, aliases sorted by key (Go's JSON encoder sorts map keys). +// The output is the exact byte form that should be committed as the embedded +// runtime catalog. +func CanonicalizeConsumerDriveProfileCatalog(data []byte) ([]byte, error) { + if err := ValidateConsumerDriveProfileCatalog(data); err != nil { + return nil, err + } + var catalog consumerDriveProfileCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("unmarshal consumer drive profiles: %w", err) + } + + //nolint:govet // field order defines the canonical JSON layout of the catalog file + ordered := struct { + Version string `json:"version,omitempty"` + Profiles []ConsumerDriveProfile `json:"profiles"` + Aliases map[string]string `json:"aliases"` + }{ + Version: catalog.Version, + Profiles: catalog.Profiles, + Aliases: catalog.Aliases, + } + + out, err := json.MarshalIndent(ordered, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles_lint.go b/webapp/backend/pkg/thresholds/consumer_drive_profiles_lint.go new file mode 100644 index 000000000..92b9cbf9b --- /dev/null +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles_lint.go @@ -0,0 +1,145 @@ +package thresholds + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" +) + +// ConsumerDriveProfileLintResult holds the outcome of a full catalog lint run. +// Errors are hard validation failures (the catalog cannot be loaded); warnings +// are quality issues that do not prevent loading but should be reviewed before +// merging catalog changes. +type ConsumerDriveProfileLintResult struct { + Warnings []string +} + +// LintConsumerDriveProfileCatalog runs the full validation and lint pass over a +// catalog document. A non-nil error means the catalog is structurally invalid +// and would fail to load at startup. Warnings flag quality issues: dead entries +// that can never pass their confidence gate, regex patterns that shadow aliases +// of other families, duplicate patterns, redundant aliases, and a missing +// catalog version. +func LintConsumerDriveProfileCatalog(data []byte) (*ConsumerDriveProfileLintResult, error) { + // Hard validation first: same path as startup loading. + if _, err := parseConsumerDriveProfiles(data); err != nil { + return nil, err + } + + var catalog consumerDriveProfileCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("unmarshal consumer drive profiles: %w", err) + } + + result := &ConsumerDriveProfileLintResult{} + if strings.TrimSpace(catalog.Version) == "" { + result.Warnings = append(result.Warnings, "catalog has no version field; set a version so API provenance can report it") + } + + lintDeadEntries(&catalog, result) + lintRedundantAliases(&catalog, result) + lintPatterns(&catalog, result) + + sort.Strings(result.Warnings) + return result, nil +} + +// lintDeadEntries flags profiles that fail their own confidence gate: they are +// bundled but can never be applied, so they are dead weight in the catalog. +func lintDeadEntries(catalog *consumerDriveProfileCatalog, result *ConsumerDriveProfileLintResult) { + for i := range catalog.Profiles { + profile := &catalog.Profiles[i] + if !profile.MeetsConfidenceThreshold() { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "dead entry: profile %q has sample_count %d below its confidence gate of %d and can never be applied", + profile.ModelFamily, profile.SampleCount, profile.EffectiveMinSamples())) + } + } +} + +// lintRedundantAliases flags aliases that normalize to the same key as the +// family they point to; the family-stage lookup already covers them. +func lintRedundantAliases(catalog *consumerDriveProfileCatalog, result *ConsumerDriveProfileLintResult) { + for alias, family := range catalog.Aliases { + if normalizeConsumerDriveKey(alias) == normalizeConsumerDriveKey(family) { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "redundant alias: %q normalizes to the same key as its family %q", alias, family)) + } + } +} + +// compiledLintPattern pairs a profile's compiled model pattern with its family +// for cross-entry shadowing checks. +type compiledLintPattern struct { + regex *regexp.Regexp + family string + pattern string +} + +// lintPatterns flags duplicate model patterns and patterns that shadow entries +// belonging to a different family (an alias or family name of family B matching +// the pattern of family A would silently reroute drives at the regex stage). +func lintPatterns(catalog *consumerDriveProfileCatalog, result *ConsumerDriveProfileLintResult) { + for _, cp := range collectLintPatterns(catalog, result) { + lintPatternShadowsAliases(catalog, cp, result) + lintPatternShadowsFamilies(catalog, cp, result) + } +} + +// collectLintPatterns compiles every profile's model pattern, warning on +// duplicates. Compile errors are caught by hard validation and skipped here. +func collectLintPatterns(catalog *consumerDriveProfileCatalog, result *ConsumerDriveProfileLintResult) []compiledLintPattern { + seenPatterns := map[string]string{} + compiled := make([]compiledLintPattern, 0, len(catalog.Profiles)) + for i := range catalog.Profiles { + profile := &catalog.Profiles[i] + if profile.ModelPattern == "" { + continue + } + if otherFamily, exists := seenPatterns[profile.ModelPattern]; exists { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "duplicate pattern: %q is declared by both %q and %q", profile.ModelPattern, otherFamily, profile.ModelFamily)) + } else { + seenPatterns[profile.ModelPattern] = profile.ModelFamily + } + if regex, err := regexp.Compile(profile.ModelPattern); err == nil { + compiled = append(compiled, compiledLintPattern{regex, profile.ModelFamily, profile.ModelPattern}) + } + } + return compiled +} + +// lintPatternShadowsAliases warns when a pattern matches an alias that belongs +// to a different family. +func lintPatternShadowsAliases(catalog *consumerDriveProfileCatalog, cp compiledLintPattern, result *ConsumerDriveProfileLintResult) { + patternFamilyKey := normalizeConsumerDriveKey(cp.family) + for alias, family := range catalog.Aliases { + if normalizeConsumerDriveKey(family) == patternFamilyKey { + continue + } + if cp.regex.MatchString(alias) { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "pattern shadowing: pattern %q of family %q matches alias %q which belongs to family %q", + cp.pattern, cp.family, alias, family)) + } + } +} + +// lintPatternShadowsFamilies warns when a pattern matches another profile's +// family name. +func lintPatternShadowsFamilies(catalog *consumerDriveProfileCatalog, cp compiledLintPattern, result *ConsumerDriveProfileLintResult) { + patternFamilyKey := normalizeConsumerDriveKey(cp.family) + for i := range catalog.Profiles { + other := &catalog.Profiles[i] + if normalizeConsumerDriveKey(other.ModelFamily) == patternFamilyKey { + continue + } + if cp.regex.MatchString(other.ModelFamily) { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "pattern shadowing: pattern %q of family %q matches family name %q", + cp.pattern, cp.family, other.ModelFamily)) + } + } +} diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles_match_test.go b/webapp/backend/pkg/thresholds/consumer_drive_profiles_match_test.go new file mode 100644 index 000000000..a0ec5c911 --- /dev/null +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles_match_test.go @@ -0,0 +1,322 @@ +package thresholds + +import ( + "os" + "strings" + "testing" +) + +func TestMatchConsumerDriveProfileMethods(t *testing.T) { + tests := []struct { + name string + modelFamily string + modelName string + expectFamily string + expectMethod ProfileMatchMethod + }{ + {"exact family", "Samsung based SSDs", "", "Samsung based SSDs", ProfileMatchMethodModelFamily}, + {"exact model alias", "", "Hitachi HDS721050DLE630", "Hitachi Deskstar 7K1000.D", ProfileMatchMethodModelName}, + {"capacity variant via vendor normalization", "", "Samsung SSD 870 EVO 2TB", "Samsung SSD 870 EVO", ProfileMatchMethodModelNameNormalized}, + {"regex pattern fallback", "", "ST2000DM001-9YN164", "Seagate Barracuda 7200.12", ProfileMatchMethodModelPattern}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := MatchConsumerDriveProfile("ATA", tt.modelFamily, tt.modelName, nil) + if match == nil { + t.Fatalf("expected a match") + } + if !match.Applied { + t.Fatalf("expected match to be applied, skip reason %q", match.SkipReason) + } + if match.Profile.ModelFamily != tt.expectFamily { + t.Fatalf("expected family %q, got %q", tt.expectFamily, match.Profile.ModelFamily) + } + if match.Method != tt.expectMethod { + t.Fatalf("expected method %q, got %q", tt.expectMethod, match.Method) + } + if match.CatalogVersion == "" { + t.Fatalf("expected catalog version to be set") + } + }) + } +} + +func TestMatchConsumerDriveProfileNoMatch(t *testing.T) { + if match := MatchConsumerDriveProfile("ATA", "", "TOSHIBA DT01ACA100", nil); match != nil { + t.Fatalf("expected no match, got %q", match.Profile.ModelFamily) + } +} + +func TestMatchConsumerDriveProfileNonAtaProtocol(t *testing.T) { + if match := MatchConsumerDriveProfile("NVMe", "Samsung based SSDs", "", nil); match != nil { + t.Fatalf("expected non-ATA protocol to bypass matching") + } +} + +func TestMatchConsumerDriveProfileDenylisted(t *testing.T) { + denied := ParseConsumerDriveProfileDenylist("Samsung based SSDs") + match := MatchConsumerDriveProfile("ATA", "Samsung based SSDs", "", denied) + if match == nil { + t.Fatalf("expected a skipped match for observability") + } + if match.Applied { + t.Fatalf("expected denylisted match to not be applied") + } + if match.SkipReason != ProfileSkipReasonFamilyDenylisted { + t.Fatalf("expected skip reason %q, got %q", ProfileSkipReasonFamilyDenylisted, match.SkipReason) + } +} + +func TestMatchConsumerDriveProfileDenylistFallsThroughToOtherFamily(t *testing.T) { + catalogJSON := `{ + "version": "test", + "profiles": [ + {"protocol":"ATA","source":"test","model_family":"Family A","sample_count":25}, + {"protocol":"ATA","source":"test","model_family":"Family B","sample_count":25} + ], + "aliases": {"MODEL-1": "Family B"} + }` + handle, err := LoadConsumerDriveProfileCatalog([]byte(catalogJSON)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + denied := ParseConsumerDriveProfileDenylist("Family A") + match := handle.Match("ATA", "Family A", "MODEL-1", denied) + if match == nil || !match.Applied { + t.Fatalf("expected fallthrough match to Family B to be applied") + } + if match.Profile.ModelFamily != "Family B" { + t.Fatalf("expected Family B, got %q", match.Profile.ModelFamily) + } + if match.Method != ProfileMatchMethodModelName { + t.Fatalf("expected model_name method, got %q", match.Method) + } +} + +func TestMatchConsumerDriveProfileBelowConfidence(t *testing.T) { + catalogJSON := `{ + "profiles": [{"protocol":"ATA","source":"test","model_family":"Family A","sample_count":5}], + "aliases": {} + }` + handle, err := LoadConsumerDriveProfileCatalog([]byte(catalogJSON)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + match := handle.Match("ATA", "Family A", "", nil) + if match == nil { + t.Fatalf("expected a skipped match for observability") + } + if match.Applied { + t.Fatalf("expected low-confidence match to not be applied") + } + if match.SkipReason != ProfileSkipReasonBelowConfidence { + t.Fatalf("expected skip reason %q, got %q", ProfileSkipReasonBelowConfidence, match.SkipReason) + } +} + +func TestParseConsumerDriveProfileDenylist(t *testing.T) { + if denied := ParseConsumerDriveProfileDenylist(""); denied != nil { + t.Fatalf("expected nil denylist for empty input") + } + if denied := ParseConsumerDriveProfileDenylist(" , ,"); denied != nil { + t.Fatalf("expected nil denylist for blank entries") + } + denied := ParseConsumerDriveProfileDenylist("Samsung based SSDs, WDC Red Plus") + if len(denied) != 2 { + t.Fatalf("expected 2 denied families, got %d", len(denied)) + } + if _, ok := denied["samsung_based_ssds"]; !ok { + t.Fatalf("expected normalized samsung key in denylist") + } + if _, ok := denied["wdc_red_plus"]; !ok { + t.Fatalf("expected normalized wdc key in denylist") + } +} + +func TestConsumerDriveModelNameVariants(t *testing.T) { + tests := []struct { + name string + modelName string + expect []string + }{ + {"capacity suffix stripped", "Samsung SSD 870 EVO 2TB", []string{"samsung_ssd_870_evo"}}, + {"wdc firmware suffix stripped", "WDC WD80EFAX-68LHPN0", []string{"wdc_wd80efax"}}, + {"seagate firmware suffix stripped", "ST4000DM000-1F2168", []string{"st4000dm000"}}, + {"no decoration yields no variants", "CT2000MX500SSD1", nil}, + {"empty input", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := consumerDriveModelNameVariants(tt.modelName) + if len(got) != len(tt.expect) { + t.Fatalf("expected variants %v, got %v", tt.expect, got) + } + for i := range tt.expect { + if got[i] != tt.expect[i] { + t.Fatalf("expected variants %v, got %v", tt.expect, got) + } + } + }) + } +} + +func TestParseConsumerDriveProfilesValidationRules(t *testing.T) { + tests := []struct { + name string + catalogJSON string + expectErr string + }{ + { + "severity ordering violation", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25, + "ata_counter_severity_overrides":{"5":{"low":0,"moderate":8,"high":4,"critical":24}}}],"aliases":{}}`, + "severity override violates", + }, + { + "observed threshold low greater than high", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25, + "ata_observed_thresholds":{"5":[{"low":10,"high":2,"annual_failure_rate":0.1}]}}],"aliases":{}}`, + "low > high", + }, + { + "annual failure rate out of range", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25, + "ata_observed_thresholds":{"5":[{"low":0,"high":4,"annual_failure_rate":1.5}]}}],"aliases":{}}`, + "annual_failure_rate outside", + }, + { + "error interval wrong length", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25, + "ata_observed_thresholds":{"5":[{"low":0,"high":4,"annual_failure_rate":0.1,"error_interval":[0.1]}]}}],"aliases":{}}`, + "error_interval must have exactly 2 values", + }, + { + "error interval not ordered", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25, + "ata_observed_thresholds":{"5":[{"low":0,"high":4,"annual_failure_rate":0.1,"error_interval":[0.3,0.1]}]}}],"aliases":{}}`, + "error_interval is not ordered", + }, + { + "non-ATA protocol", + `{"profiles":[{"protocol":"NVMe","source":"t","model_family":"F","sample_count":25}],"aliases":{}}`, + "unsupported protocol", + }, + { + "missing source", + `{"profiles":[{"protocol":"ATA","source":"","model_family":"F","sample_count":25}],"aliases":{}}`, + "missing source", + }, + { + "non-positive sample count", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":0}],"aliases":{}}`, + "non-positive sample_count", + }, + { + "negative min samples", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25,"min_samples":-1}],"aliases":{}}`, + "negative min_samples", + }, + { + "invalid regex pattern", + `{"profiles":[{"protocol":"ATA","source":"t","model_family":"F","sample_count":25,"model_pattern":"^(["}],"aliases":{}}`, + "compile model_pattern", + }, + { + "duplicate family", + `{"profiles":[ + {"protocol":"ATA","source":"t","model_family":"Family A","sample_count":25}, + {"protocol":"ATA","source":"t","model_family":"family a","sample_count":25}],"aliases":{}}`, + "duplicate profile family", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseConsumerDriveProfiles([]byte(tt.catalogJSON)) + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestLintConsumerDriveProfileCatalogWarnings(t *testing.T) { + catalogJSON := `{ + "profiles": [ + {"protocol":"ATA","source":"t","model_family":"Family A","sample_count":25,"min_samples":100}, + {"protocol":"ATA","source":"t","model_family":"Family B","sample_count":25,"model_pattern":"^MODEL-.*$"}, + {"protocol":"ATA","source":"t","model_family":"Family C","sample_count":25,"model_pattern":"^MODEL-.*$"} + ], + "aliases": { + "Family A": "Family A", + "MODEL-9": "Family A" + } + }` + + result, err := LintConsumerDriveProfileCatalog([]byte(catalogJSON)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectWarnings := []string{ + "catalog has no version", + "dead entry", + "redundant alias", + "duplicate pattern", + "pattern shadowing", + } + joined := strings.Join(result.Warnings, "\n") + for _, expected := range expectWarnings { + if !strings.Contains(joined, expected) { + t.Fatalf("expected a warning containing %q, got:\n%s", expected, joined) + } + } +} + +func TestBundledCatalogLintClean(t *testing.T) { + result, err := LintConsumerDriveProfileCatalog(consumerDriveProfilesJSON) + if err != nil { + t.Fatalf("bundled catalog failed validation: %v", err) + } + if len(result.Warnings) > 0 { + t.Fatalf("bundled catalog has lint warnings:\n%s", strings.Join(result.Warnings, "\n")) + } +} + +func TestBundledCatalogIsCanonical(t *testing.T) { + canonical, err := CanonicalizeConsumerDriveProfileCatalog(consumerDriveProfilesJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(canonical) != string(consumerDriveProfilesJSON) { + t.Fatalf("bundled catalog is not canonical; run `make catalog-fix`") + } +} + +func TestBundledCatalogExpectedMatchFixtures(t *testing.T) { + fixtureData, err := os.ReadFile("testdata/consumer_drive_profile_fixtures.json") + if err != nil { + t.Fatalf("read fixtures: %v", err) + } + handle, err := LoadConsumerDriveProfileCatalog(consumerDriveProfilesJSON) + if err != nil { + t.Fatalf("load bundled catalog: %v", err) + } + failures, err := CheckConsumerDriveProfileFixtures(handle, fixtureData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(failures) > 0 { + t.Fatalf("fixture failures:\n%s", strings.Join(failures, "\n")) + } +} + +func TestConsumerDriveProfileCatalogVersionSet(t *testing.T) { + if ConsumerDriveProfileCatalogVersion() == "" { + t.Fatalf("bundled catalog must declare a version") + } +} diff --git a/webapp/backend/pkg/thresholds/consumer_drive_profiles_test.go b/webapp/backend/pkg/thresholds/consumer_drive_profiles_test.go index 511acfbeb..ed665ef15 100644 --- a/webapp/backend/pkg/thresholds/consumer_drive_profiles_test.go +++ b/webapp/backend/pkg/thresholds/consumer_drive_profiles_test.go @@ -125,6 +125,19 @@ func TestLookupConsumerDriveProfileByCrucialMx300Alias(t *testing.T) { } } +func TestLookupConsumerDriveProfileByCrucialRealSsdFamily(t *testing.T) { + profile, ok := LookupConsumerDriveProfile("ATA", "Crucial/Micron RealSSD m4/C400/P400", "") + if !ok || profile == nil { + t.Fatalf("expected Crucial RealSSD family match") + } + if profile.ModelFamily != "Crucial/Micron RealSSD m4/C400/P400" { + t.Fatalf("unexpected model family: %s", profile.ModelFamily) + } + if _, ok := profile.AtaObservedThresholds[189]; !ok { + t.Fatalf("expected attr 189 override") + } +} + func TestLookupConsumerDriveProfileByCrucialBx500Regex4TB(t *testing.T) { profile, ok := LookupConsumerDriveProfile("ATA", "", "CT4000BX500SSD1") if !ok || profile == nil { @@ -203,7 +216,7 @@ func TestParseConsumerDriveProfilesRejectsConflictingDuplicateAlias(t *testing.T ], "aliases":{"Model A":"Family A","Model_A":"Family B"} }` - _, _, _, err := parseConsumerDriveProfiles([]byte(invalidJSON)) + _, err := parseConsumerDriveProfiles([]byte(invalidJSON)) if err == nil || !strings.Contains(err.Error(), "duplicate model alias") { t.Fatalf("expected duplicate alias error, got %v", err) } @@ -214,7 +227,7 @@ func TestParseConsumerDriveProfilesRejectsUnknownFamilyAlias(t *testing.T) { "profiles":[{"protocol":"ATA","source":"test","model_family":"Family A","sample_count":25}], "aliases":{"Model A":"Missing Family"} }` - _, _, _, err := parseConsumerDriveProfiles([]byte(invalidJSON)) + _, err := parseConsumerDriveProfiles([]byte(invalidJSON)) if err == nil || !strings.Contains(err.Error(), "unknown family") { t.Fatalf("expected unknown family alias error, got %v", err) } diff --git a/webapp/backend/pkg/thresholds/testdata/consumer_drive_profile_fixtures.json b/webapp/backend/pkg/thresholds/testdata/consumer_drive_profile_fixtures.json new file mode 100644 index 000000000..d9746ffce --- /dev/null +++ b/webapp/backend/pkg/thresholds/testdata/consumer_drive_profile_fixtures.json @@ -0,0 +1,126 @@ +[ + { + "description": "exact model_family hit wins over model name stages", + "protocol": "ATA", + "model_family": "Seagate Barracuda 7200.14 (AF)", + "model_name": "ST4000DM000-1F2168", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Seagate Barracuda 7200.14 (AF)", + "expect_method": "model_family" + }, + { + "description": "exact model_family hit for Samsung based SSDs", + "protocol": "ATA", + "model_family": "Samsung based SSDs", + "model_name": "", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Samsung based SSDs", + "expect_method": "model_family" + }, + { + "description": "exact alias hit for Hitachi model string", + "protocol": "ATA", + "model_family": "", + "model_name": "Hitachi HDS721050DLE630", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Hitachi Deskstar 7K1000.D", + "expect_method": "model_name" + }, + { + "description": "underscored WDC model string normalizes to the spaced alias", + "protocol": "ATA", + "model_family": "", + "model_name": "WDC_WD140EDFZ-11A0VA0", + "expect_matched": true, + "expect_applied": true, + "expect_family": "WDC Red Plus", + "expect_method": "model_name" + }, + { + "description": "capacity variant without alias resolves via vendor normalization", + "protocol": "ATA", + "model_family": "", + "model_name": "Samsung SSD 870 EVO 2TB", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Samsung SSD 870 EVO", + "expect_method": "model_name_normalized" + }, + { + "description": "870 QVO capacity variant without alias resolves via vendor normalization", + "protocol": "ATA", + "model_family": "", + "model_name": "Samsung SSD 870 QVO 4TB", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Samsung SSD 870 QVO", + "expect_method": "model_name_normalized" + }, + { + "description": "Crucial capacity variant without alias falls back to model_pattern", + "protocol": "ATA", + "model_family": "", + "model_name": "CT2000MX500SSD1", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Crucial MX500", + "expect_method": "model_pattern" + }, + { + "description": "Seagate firmware suffix variant without alias falls back to model_pattern", + "protocol": "ATA", + "model_family": "", + "model_name": "ST2000DM001-9YN164", + "expect_matched": true, + "expect_applied": true, + "expect_family": "Seagate Barracuda 7200.12", + "expect_method": "model_pattern" + }, + { + "description": "WDC Red firmware variant without alias falls back to model_pattern", + "protocol": "ATA", + "model_family": "", + "model_name": "WDC WD30EFRX-68EUZN0", + "expect_matched": true, + "expect_applied": true, + "expect_family": "WDC Red", + "expect_method": "model_pattern" + }, + { + "description": "WDC Caviar Blue firmware variant without alias falls back to model_pattern", + "protocol": "ATA", + "model_family": "", + "model_name": "WD10EZEX-22MFCA0", + "expect_matched": true, + "expect_applied": true, + "expect_family": "WDC Caviar Blue", + "expect_method": "model_pattern" + }, + { + "description": "unknown Toshiba drive stays unmatched and falls back to generic ATA rules", + "protocol": "ATA", + "model_family": "", + "model_name": "TOSHIBA DT01ACA100", + "expect_matched": false, + "expect_applied": false + }, + { + "description": "unknown model family with unknown model stays unmatched", + "protocol": "ATA", + "model_family": "Some Unknown Family", + "model_name": "UNKNOWN-DRIVE-123", + "expect_matched": false, + "expect_applied": false + }, + { + "description": "non-ATA protocol bypasses the catalog entirely", + "protocol": "NVMe", + "model_family": "Samsung based SSDs", + "model_name": "Samsung SSD 860 EVO 500GB", + "expect_matched": false, + "expect_applied": false + } +] diff --git a/webapp/backend/pkg/version/version.go b/webapp/backend/pkg/version/version.go index 1a499fe9d..aa8a84d08 100644 --- a/webapp/backend/pkg/version/version.go +++ b/webapp/backend/pkg/version/version.go @@ -2,4 +2,4 @@ package version // VERSION is the app-global version string. semantic-release updates this // constant in the release commit, and release binaries are built from that tag. -const VERSION = "1.65.0" +const VERSION = "1.66.0" diff --git a/webapp/backend/pkg/web/handler/get_device_drive_profile.go b/webapp/backend/pkg/web/handler/get_device_drive_profile.go new file mode 100644 index 000000000..82147f382 --- /dev/null +++ b/webapp/backend/pkg/web/handler/get_device_drive_profile.go @@ -0,0 +1,135 @@ +package handler + +import ( + "net/http" + "sort" + + "github.com/analogj/scrutiny/webapp/backend/pkg" + "github.com/analogj/scrutiny/webapp/backend/pkg/config" + "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// GetDeviceDriveProfile returns consumer drive profile match diagnostics for a +// device: which catalog entry matched (if any), the match method, the +// confidence gate result, which overrides would be applied, and why the drive +// falls back to generic ATA rules when no profile is in effect. +// +// The catalog match is always computed (even when the feature is globally +// disabled) so operators can inspect what WOULD happen; the Applied flag +// reflects the effective state. +// +// Response: models.DriveProfileInspectionResponse +func GetDeviceDriveProfile(c *gin.Context) { + logger := c.MustGet("LOGGER").(*logrus.Entry) + deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) + appConfig := c.MustGet("CONFIG").(config.Interface) + + device, err := ResolveDevice(c, logger, deviceRepo) + if err != nil { + return + } + + profilesEnabled := consumerDriveProfilesEnabled(appConfig) + denied := consumerDriveProfileDenylist(appConfig) + + inspection := models.DriveProfileInspection{ + DeviceWWN: device.WWN, + DeviceProtocol: device.DeviceProtocol, + ModelFamily: device.ModelFamily, + ModelName: device.ModelName, + ProfilesEnabled: profilesEnabled, + Denylist: sortedDenylistKeys(denied), + CatalogVersion: thresholds.ConsumerDriveProfileCatalogVersion(), + } + + match := thresholds.MatchConsumerDriveProfile(device.DeviceProtocol, device.ModelFamily, device.ModelName, denied) + populateDriveProfileMatch(&inspection, match, profilesEnabled) + + c.JSON(http.StatusOK, models.DriveProfileInspectionResponse{ + Success: true, + Data: inspection, + }) +} + +// populateDriveProfileMatch fills match, override, and fallback-reason fields +// on the inspection from a catalog match result. +func populateDriveProfileMatch(inspection *models.DriveProfileInspection, match *thresholds.ConsumerDriveProfileMatch, profilesEnabled bool) { + if match == nil { + switch { + case inspection.DeviceProtocol != pkg.DeviceProtocolAta: + inspection.FallbackReason = "Device protocol is not ATA; consumer drive profiles apply to ATA drives only." + case !profilesEnabled: + inspection.FallbackReason = "Consumer drive profiles are disabled globally in Settings." + default: + inspection.FallbackReason = "No profile in the bundled catalog matched this drive's model family or model name." + } + return + } + + profile := match.Profile + inspection.Matched = true + inspection.MatchMethod = string(match.Method) + inspection.MatchedValue = match.MatchedValue + inspection.ProfileFamily = profile.ModelFamily + inspection.ProfileVendor = profile.Vendor + inspection.ProfileSource = profile.Source + inspection.SampleCount = profile.SampleCount + inspection.MinSamples = profile.EffectiveMinSamples() + inspection.ConfidenceMet = profile.MeetsConfidenceThreshold() + inspection.ObservedThresholdAttributes = sortedIntKeys(profile.AtaObservedThresholds) + inspection.CounterSeverityAttributes = sortedStringKeys(profile.AtaCounterSeverityOverrides) + + if !profilesEnabled { + inspection.FallbackReason = "Consumer drive profiles are disabled globally in Settings; the matched profile is not applied." + return + } + + switch match.SkipReason { + case thresholds.ProfileSkipReasonFamilyDenylisted: + inspection.FallbackReason = "Matched family is denylisted in Settings; generic ATA rules are in effect." + case thresholds.ProfileSkipReasonBelowConfidence: + inspection.FallbackReason = "Matched profile does not meet its minimum sample-count confidence gate; generic ATA rules are in effect." + default: + inspection.Applied = match.Applied + } +} + +func sortedDenylistKeys(denied map[string]struct{}) []string { + if len(denied) == 0 { + return nil + } + keys := make([]string, 0, len(denied)) + for key := range denied { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedIntKeys(m map[int][]thresholds.ObservedThreshold) []int { + if len(m) == 0 { + return nil + } + keys := make([]int, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Ints(keys) + return keys +} + +func sortedStringKeys(m map[string]thresholds.CounterSeverityProfile) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/webapp/backend/pkg/web/handler/get_device_drive_profile_test.go b/webapp/backend/pkg/web/handler/get_device_drive_profile_test.go new file mode 100644 index 000000000..2f2dcbef8 --- /dev/null +++ b/webapp/backend/pkg/web/handler/get_device_drive_profile_test.go @@ -0,0 +1,99 @@ +package handler + +import ( + "testing" + + "github.com/analogj/scrutiny/webapp/backend/pkg" + "github.com/analogj/scrutiny/webapp/backend/pkg/config" + mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestConsumerDriveProfileDenylistParsesConfiguredFamilies(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + cfg := mock_config.NewMockInterface(mockCtrl) + key := config.DB_USER_SETTINGS_SUBKEY + ".metrics.consumer_drive_profiles_denylist" + cfg.EXPECT().GetString(key).Return("Samsung based SSDs, WDC Red Plus") + + denied := consumerDriveProfileDenylist(cfg) + require.Len(t, denied, 2) + require.Contains(t, denied, "samsung_based_ssds") + require.Contains(t, denied, "wdc_red_plus") +} + +func TestConsumerDriveProfileDenylistEmptyWhenUnset(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + cfg := mock_config.NewMockInterface(mockCtrl) + key := config.DB_USER_SETTINGS_SUBKEY + ".metrics.consumer_drive_profiles_denylist" + cfg.EXPECT().GetString(key).Return("") + + require.Nil(t, consumerDriveProfileDenylist(cfg)) +} + +func TestPopulateDriveProfileMatchApplied(t *testing.T) { + inspection := models.DriveProfileInspection{DeviceProtocol: pkg.DeviceProtocolAta} + match := thresholds.MatchConsumerDriveProfile(pkg.DeviceProtocolAta, "Samsung based SSDs", "", nil) + require.NotNil(t, match) + + populateDriveProfileMatch(&inspection, match, true) + + require.True(t, inspection.Matched) + require.True(t, inspection.Applied) + require.Equal(t, "model_family", inspection.MatchMethod) + require.Equal(t, "Samsung based SSDs", inspection.ProfileFamily) + require.True(t, inspection.ConfidenceMet) + require.NotEmpty(t, inspection.ProfileSource) + require.NotEmpty(t, inspection.CounterSeverityAttributes) + require.Empty(t, inspection.FallbackReason) +} + +func TestPopulateDriveProfileMatchDenylisted(t *testing.T) { + inspection := models.DriveProfileInspection{DeviceProtocol: pkg.DeviceProtocolAta} + denied := thresholds.ParseConsumerDriveProfileDenylist("Samsung based SSDs") + match := thresholds.MatchConsumerDriveProfile(pkg.DeviceProtocolAta, "Samsung based SSDs", "", denied) + require.NotNil(t, match) + + populateDriveProfileMatch(&inspection, match, true) + + require.True(t, inspection.Matched) + require.False(t, inspection.Applied) + require.Contains(t, inspection.FallbackReason, "denylisted") +} + +func TestPopulateDriveProfileMatchGloballyDisabled(t *testing.T) { + inspection := models.DriveProfileInspection{DeviceProtocol: pkg.DeviceProtocolAta} + match := thresholds.MatchConsumerDriveProfile(pkg.DeviceProtocolAta, "Samsung based SSDs", "", nil) + require.NotNil(t, match) + + populateDriveProfileMatch(&inspection, match, false) + + require.True(t, inspection.Matched) + require.False(t, inspection.Applied) + require.Contains(t, inspection.FallbackReason, "disabled globally") +} + +func TestPopulateDriveProfileMatchNoMatch(t *testing.T) { + inspection := models.DriveProfileInspection{DeviceProtocol: pkg.DeviceProtocolAta} + + populateDriveProfileMatch(&inspection, nil, true) + + require.False(t, inspection.Matched) + require.False(t, inspection.Applied) + require.Contains(t, inspection.FallbackReason, "No profile in the bundled catalog") +} + +func TestPopulateDriveProfileMatchNonAtaProtocol(t *testing.T) { + inspection := models.DriveProfileInspection{DeviceProtocol: pkg.DeviceProtocolNvme} + + populateDriveProfileMatch(&inspection, nil, true) + + require.False(t, inspection.Matched) + require.Contains(t, inspection.FallbackReason, "not ATA") +} diff --git a/webapp/backend/pkg/web/handler/get_device_replacement_risk.go b/webapp/backend/pkg/web/handler/get_device_replacement_risk.go index 321638cea..84cc1f070 100644 --- a/webapp/backend/pkg/web/handler/get_device_replacement_risk.go +++ b/webapp/backend/pkg/web/handler/get_device_replacement_risk.go @@ -75,9 +75,13 @@ func GetDeviceReplacementRisk(c *gin.Context) { weights := thresholds.ReplacementRiskWeightsForProtocol(device.DeviceProtocol) profilesEnabled := consumerDriveProfilesEnabled(appConfig) + var match *thresholds.ConsumerDriveProfileMatch var profile *thresholds.ConsumerDriveProfile if profilesEnabled { - profile, _ = thresholds.LookupConsumerDriveProfile(device.DeviceProtocol, device.ModelFamily, device.ModelName) + match = thresholds.MatchConsumerDriveProfile(device.DeviceProtocol, device.ModelFamily, device.ModelName, consumerDriveProfileDenylist(appConfig)) + if match != nil && match.Applied { + profile = match.Profile + } } contributions, totalScore, totalTrendBonus := computeRiskContributions(weights, latestAttrs, oldestAttrs, profile) @@ -100,6 +104,10 @@ func GetDeviceReplacementRisk(c *gin.Context) { } if profile != nil { riskScore.ConsumerDriveProfileFamily = profile.ModelFamily + riskScore.ConsumerDriveProfileSource = profile.Source + riskScore.ConsumerDriveProfileSampleCount = profile.SampleCount + riskScore.ConsumerDriveProfileMatchMethod = string(match.Method) + riskScore.ConsumerDriveProfileCatalogVersion = match.CatalogVersion } c.JSON(http.StatusOK, models.ReplacementRiskResponse{ @@ -119,6 +127,16 @@ func consumerDriveProfilesEnabled(cfg config.Interface) bool { return cfg.GetBool(key) } +// consumerDriveProfileDenylist returns the set of normalized family keys the +// operator has excluded from profile matching via settings. +func consumerDriveProfileDenylist(cfg config.Interface) map[string]struct{} { + if cfg == nil { + return nil + } + return thresholds.ParseConsumerDriveProfileDenylist( + cfg.GetString(config.DB_USER_SETTINGS_SUBKEY + ".metrics.consumer_drive_profiles_denylist")) +} + // computeRiskContributions builds the per-attribute contribution list and returns // the total score and total trend bonus. func computeRiskContributions( diff --git a/webapp/backend/pkg/web/handler/replacement_risk_notify.go b/webapp/backend/pkg/web/handler/replacement_risk_notify.go index 484adadae..5d542a6cc 100644 --- a/webapp/backend/pkg/web/handler/replacement_risk_notify.go +++ b/webapp/backend/pkg/web/handler/replacement_risk_notify.go @@ -48,7 +48,9 @@ func maybeNotifyReplacementRisk( weights := thresholds.ReplacementRiskWeightsForProtocol(device.DeviceProtocol) var profile *thresholds.ConsumerDriveProfile if consumerDriveProfilesEnabled(appConfig) { - profile, _ = thresholds.LookupConsumerDriveProfile(device.DeviceProtocol, device.ModelFamily, device.ModelName) + if match := thresholds.MatchConsumerDriveProfile(device.DeviceProtocol, device.ModelFamily, device.ModelName, consumerDriveProfileDenylist(appConfig)); match != nil && match.Applied { + profile = match.Profile + } } _, totalScore, _ := computeRiskContributions(weights, latestAttrs, oldestAttrs, profile) diff --git a/webapp/backend/pkg/web/server.go b/webapp/backend/pkg/web/server.go index 0feec42e4..bc9ee29e8 100644 --- a/webapp/backend/pkg/web/server.go +++ b/webapp/backend/pkg/web/server.go @@ -153,6 +153,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { api.POST("/device/:id/performance", handler.UploadDevicePerformance) // used by Collector to upload performance benchmarks api.GET("/device/:id/performance", handler.GetDevicePerformance) // used by UI to view performance history api.GET("/device/:id/replacement-risk", handler.GetDeviceReplacementRisk) // used by UI to display drive replacement prediction + api.GET("/device/:id/drive-profile", handler.GetDeviceDriveProfile) // debug/inspection surface for consumer drive profile matching api.POST("/device/:id/collector-error", handler.UploadCollectorError) // used by Collector to report smartctl errors api.POST("/collector/scan-error", handler.UploadCollectorScanError) // used by Collector to report scan-level errors (no device context) diff --git a/webapp/frontend/package-lock.json b/webapp/frontend/package-lock.json index 35a975021..c5aa80611 100644 --- a/webapp/frontend/package-lock.json +++ b/webapp/frontend/package-lock.json @@ -39,9 +39,9 @@ "@angular-eslint/builder": "^21.3.1", "@angular-eslint/eslint-plugin": "^21.3.1", "@angular-eslint/eslint-plugin-template": "^21.3.1", - "@angular-eslint/template-parser": "^21.3.1", + "@angular-eslint/template-parser": "^22.0.0", "@angular/build": "^21.2.10", - "@angular/cli": "^21.2.10", + "@angular/cli": "^22.0.5", "@angular/compiler-cli": "^21.2.12", "@angular/language-service": "^21.2.12", "@types/crypto-js": "^4.1.1", @@ -49,12 +49,12 @@ "@types/jasmine": "^4.3.0", "@types/lodash": "~4.17.24", "@types/node": "^20.19.27", - "@typescript-eslint/eslint-plugin": "^8.60.1", - "@typescript-eslint/parser": "^8.60.1", - "apexcharts": "~5.14.0", + "@typescript-eslint/eslint-plugin": "^8.62.0", + "@typescript-eslint/parser": "^8.62.0", + "apexcharts": "~5.15.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.2", - "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-prettier": "^5.5.6", "jasmine-core": "^4.5.0", "jasmine-spec-reporter": "^7.0.0", "karma": "^6.4.1", @@ -70,57 +70,57 @@ } }, "node_modules/@algolia/abtesting": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", - "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz", + "integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-abtesting": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", - "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz", + "integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", - "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz", + "integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", - "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz", + "integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==", "dev": true, "license": "MIT", "engines": { @@ -128,151 +128,151 @@ } }, "node_modules/@algolia/client-insights": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", - "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz", + "integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", - "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz", + "integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", - "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz", + "integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", - "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz", + "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", - "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz", + "integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", - "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz", + "integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", - "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz", + "integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", - "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz", + "integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", - "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz", + "integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", - "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz", + "integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.48.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" @@ -411,9 +411,9 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.3.1.tgz", - "integrity": "sha512-jjbnJPUXQeQBJ8RM+ahlbt4GH2emVN8JvG3AhFbPci1FrqXi9cOOfkbwLmvpoyTli4LF8gy7g4ctFqnlRgqryw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-22.0.0.tgz", + "integrity": "sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==", "dev": true, "license": "MIT" }, @@ -492,17 +492,17 @@ } }, "node_modules/@angular-eslint/template-parser": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.3.1.tgz", - "integrity": "sha512-moERVCTekQKOvR8RMuEOtWSO3VS1qrzA3keI1dPto/JVB8Nqp9w3R5ZpEoXHzh4zgEryosxmPgdi6UczJe2ouQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-22.0.0.tgz", + "integrity": "sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.1", + "@angular-eslint/bundled-angular-compiler": "22.0.0", "eslint-scope": "9.1.2" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint": "^9.0.0 || ^10.0.0", "typescript": "*" } }, @@ -704,55 +704,121 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.10.tgz", - "integrity": "sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==", + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.0.5.tgz", + "integrity": "sha512-BYg2dWsbqdbpv3UVLNtDns1DoUbsROR8htPPIJEETj4Nfu7VF+g0nnM1CX2Ia+3002xtASgHp4YnRGQN/vAhWA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.10", - "@angular-devkit/core": "21.2.10", - "@angular-devkit/schematics": "21.2.10", - "@inquirer/prompts": "7.10.1", - "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.10", + "@angular-devkit/architect": "0.2200.5", + "@angular-devkit/core": "22.0.5", + "@angular-devkit/schematics": "22.0.5", + "@inquirer/prompts": "8.4.2", + "@listr2/prompt-adapter-inquirer": "4.2.3", + "@modelcontextprotocol/sdk": "1.29.0", + "@schematics/angular": "22.0.5", "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.48.1", + "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", - "listr2": "9.0.5", + "listr2": "10.2.1", "npm-package-arg": "13.0.2", - "pacote": "21.3.1", - "parse5-html-rewriting-stream": "8.0.0", + "pacote": "21.5.1", + "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", - "zod": "4.3.6" + "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.2200.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2200.5.tgz", + "integrity": "sha512-Fbki7xEx37gn8uH5AFOh0EmfGV4y5xag2fTv1vn6KxY/h33L02KuAjiUX3h2YmSjnYSc1dXto3Q5c4lWs/I5Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.5", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular/cli/node_modules/@listr2/prompt-adapter-inquirer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", - "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.5.tgz", + "integrity": "sha512-xxd3b9X0bbdGuYqDj1OgxtlGotrb/gsxQ5+4aqivWBHq4q/1RI8JfVB7gqcoYTvAfTq63Dwsk7Qd33hC4yJ1Wg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/type": "^3.0.8" + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" }, "engines": { - "node": ">=20.0.0" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" }, "peerDependencies": { - "@inquirer/prompts": ">= 3 < 8", - "listr2": "9.0.5" + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.0.5.tgz", + "integrity": "sha512-yKkImZuFLIbylmk1REyoW3EWQeqnr6qwtrzs4EPeaJgzjj+MZurq5slQ3fanK9lfX2q+W+dnSQFKBXHxlUGJ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.5", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.4.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/@angular/cli/node_modules/ansi-regex": { @@ -768,6 +834,32 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/@angular/cli/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@angular/cli/node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -783,19 +875,6 @@ "node": ">=20" } }, - "node_modules/@angular/cli/node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@angular/cli/node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -814,22 +893,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular/cli/node_modules/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@angular/cli/node_modules/cliui/node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -855,6 +918,142 @@ "dev": true, "license": "MIT" }, + "node_modules/@angular/cli/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/cli/node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@angular/cli/node_modules/ora": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular/cli/node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@angular/cli/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular/cli/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@angular/cli/node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -901,22 +1100,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular/cli/node_modules/yargs/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@angular/common": { "version": "21.2.12", "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.12.tgz", @@ -2150,9 +2333,9 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "dev": true, "license": "MIT", "engines": { @@ -2211,20 +2394,19 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2235,21 +2417,96 @@ } } }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "node_modules/@inquirer/checkbox/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" }, "peerDependenciesMeta": { "@types/node": { @@ -2301,18 +2558,83 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2323,19 +2645,93 @@ } } }, + "node_modules/@inquirer/editor/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2346,18 +2742,533 @@ } } }, + "node_modules/@inquirer/expand/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/prompts/node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/prompts/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2368,45 +3279,24 @@ } } }, - "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "node_modules/@inquirer/rawlist/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "node_modules/@inquirer/rawlist/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2417,18 +3307,29 @@ } } }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "node_modules/@inquirer/rawlist/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2439,19 +3340,33 @@ } } }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "node_modules/@inquirer/search/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2462,26 +3377,24 @@ } } }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "node_modules/@inquirer/search/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2492,19 +3405,30 @@ } } }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "node_modules/@inquirer/search/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2515,20 +3439,33 @@ } } }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "node_modules/@inquirer/select/node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2539,21 +3476,24 @@ } } }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "node_modules/@inquirer/select/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -2564,6 +3504,16 @@ } } }, + "node_modules/@inquirer/select/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@inquirer/type": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", @@ -2654,6 +3604,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.3.tgz", + "integrity": "sha512-Co9U3AJ3LW0J8XBHjVoNKA79dMAyFt8EZH3OaKTMcDTj8r+6kG3vSUPq/eGLHT7P0iK3uLaFfhdFYd3033P24g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "10.2.1" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", @@ -2753,9 +3738,9 @@ ] }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3306,9 +4291,9 @@ } }, "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", "dev": true, "license": "ISC", "dependencies": { @@ -3323,9 +4308,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3376,9 +4361,9 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3489,13 +4474,13 @@ } }, "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -3899,13 +4884,13 @@ "optional": true }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -4456,54 +5441,225 @@ ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.0.5.tgz", + "integrity": "sha512-e+1Ob5Kxt9QH2Gige0Bl56CXclG819RMl5Fso3kwO58+9AQnR2QxvjbetB7LDRl+YEG3TXCE4s3vdrV4gqRRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.5", + "@angular-devkit/schematics": "22.0.5", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.5.tgz", + "integrity": "sha512-xxd3b9X0bbdGuYqDj1OgxtlGotrb/gsxQ5+4aqivWBHq4q/1RI8JfVB7gqcoYTvAfTq63Dwsk7Qd33hC4yJ1Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.0.5.tgz", + "integrity": "sha512-yKkImZuFLIbylmk1REyoW3EWQeqnr6qwtrzs4EPeaJgzjj+MZurq5slQ3fanK9lfX2q+W+dnSQFKBXHxlUGJ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.5", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.4.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@schematics/angular/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@schematics/angular/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@schematics/angular/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" - ], + "node_modules/@schematics/angular/node_modules/ora": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], + "node_modules/@schematics/angular/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@schematics/angular/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@schematics/angular": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.10.tgz", - "integrity": "sha512-RWoD2iARXfHmMkAzmAsefj5rcyihhVPW4OY7+pdpfFYCHdGPreSbEAhCcTF2dJjJA/71N5qj5bFdSIJhO2aZ1A==", + "node_modules/@schematics/angular/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.10", - "@angular-devkit/schematics": "21.2.10", - "jsonc-parser": "3.3.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@schematics/angular/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, "node_modules/@sigstore/bundle": { @@ -4530,9 +5686,9 @@ } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", - "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4675,13 +5831,13 @@ } }, "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -4774,17 +5930,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "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==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@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", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4797,7 +5953,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4813,16 +5969,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@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", "debug": "^4.4.3" }, "engines": { @@ -4863,14 +6019,14 @@ "license": "MIT" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -4910,14 +6066,14 @@ "license": "MIT" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4928,9 +6084,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "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==", "dev": true, "license": "MIT", "engines": { @@ -4945,15 +6101,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4995,9 +6151,9 @@ "license": "MIT" }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -5009,16 +6165,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@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", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5101,16 +6257,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5125,13 +6281,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5320,26 +6476,26 @@ "license": "MIT" }, "node_modules/algoliasearch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", - "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz", + "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/abtesting": "1.14.1", - "@algolia/client-abtesting": "5.48.1", - "@algolia/client-analytics": "5.48.1", - "@algolia/client-common": "5.48.1", - "@algolia/client-insights": "5.48.1", - "@algolia/client-personalization": "5.48.1", - "@algolia/client-query-suggestions": "5.48.1", - "@algolia/client-search": "5.48.1", - "@algolia/ingestion": "1.48.1", - "@algolia/monitoring": "1.48.1", - "@algolia/recommend": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" + "@algolia/abtesting": "1.18.0", + "@algolia/client-abtesting": "5.52.0", + "@algolia/client-analytics": "5.52.0", + "@algolia/client-common": "5.52.0", + "@algolia/client-insights": "5.52.0", + "@algolia/client-personalization": "5.52.0", + "@algolia/client-query-suggestions": "5.52.0", + "@algolia/client-search": "5.52.0", + "@algolia/ingestion": "1.52.0", + "@algolia/monitoring": "1.52.0", + "@algolia/recommend": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" @@ -5422,9 +6578,9 @@ } }, "node_modules/apexcharts": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.14.0.tgz", - "integrity": "sha512-hBLz5Gd8B5ZuocEzNUwEeKdw9vd7uy4OnqbDjAYyvwiAEyHk3OnO9+tGzAznpM6pnyCqWjPXUKKYoNIwdskeuQ==", + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.2.tgz", + "integrity": "sha512-qbd+ehDiRxo++wAqaGLmJcd683ktulTqoku4WYYyQ3XOgJIn4yhOQGV27KcdK7c2yhwxblyh4mgMm8ukI0wC0Q==", "dev": true, "license": "SEE LICENSE IN LICENSE" }, @@ -5709,9 +6865,9 @@ } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5719,13 +6875,13 @@ } }, "node_modules/cacache/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -5824,9 +6980,9 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, "license": "MIT" }, @@ -5893,14 +7049,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -5922,15 +7078,61 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -5940,13 +7142,13 @@ } }, "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6055,9 +7257,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", "engines": { @@ -6544,13 +7746,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6714,14 +7909,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.11.13" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -6915,9 +8110,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "dev": true, "license": "MIT", "engines": { @@ -6976,9 +8171,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", "dependencies": { @@ -7009,21 +8204,21 @@ } }, "node_modules/express/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -7033,6 +8228,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/express/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7171,18 +8380,36 @@ } }, "node_modules/express/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/extend": { @@ -7236,6 +8463,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -7252,6 +8496,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -7736,9 +8990,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -7749,9 +9003,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7948,13 +9202,13 @@ } }, "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8333,9 +9587,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, "license": "MIT", "funding": { @@ -9000,9 +10254,9 @@ "license": "ISC" }, "node_modules/make-fetch-happen": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", "dev": true, "license": "ISC", "dependencies": { @@ -9499,21 +10753,21 @@ "optional": true }, "node_modules/node-gyp": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -9549,6 +10803,16 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -9920,9 +11184,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", "dev": true, "license": "MIT", "engines": { @@ -9933,12 +11197,13 @@ } }, "node_modules/pacote": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", - "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", "dev": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", @@ -9952,7 +11217,6 @@ "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" @@ -10094,9 +11358,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -10104,9 +11368,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -10410,20 +11674,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10624,16 +11874,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11335,13 +12575,13 @@ "license": "MIT" }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -11444,9 +12684,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "license": "MIT", "engines": { "node": ">=18" @@ -11595,13 +12835,13 @@ } }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -11694,9 +12934,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12365,9 +13605,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "dev": true, "license": "MIT", "funding": { diff --git a/webapp/frontend/package.json b/webapp/frontend/package.json index 23d0831c4..f1b8ddfc2 100644 --- a/webapp/frontend/package.json +++ b/webapp/frontend/package.json @@ -51,9 +51,9 @@ "@angular-eslint/builder": "^21.3.1", "@angular-eslint/eslint-plugin": "^21.3.1", "@angular-eslint/eslint-plugin-template": "^21.3.1", - "@angular-eslint/template-parser": "^21.3.1", + "@angular-eslint/template-parser": "^22.0.0", "@angular/build": "^21.2.10", - "@angular/cli": "^21.2.10", + "@angular/cli": "^22.0.5", "@angular/compiler-cli": "^21.2.12", "@angular/language-service": "^21.2.12", "@types/crypto-js": "^4.1.1", @@ -61,12 +61,12 @@ "@types/jasmine": "^4.3.0", "@types/lodash": "~4.17.24", "@types/node": "^20.19.27", - "@typescript-eslint/eslint-plugin": "^8.60.1", - "@typescript-eslint/parser": "^8.60.1", - "apexcharts": "~5.14.0", + "@typescript-eslint/eslint-plugin": "^8.62.0", + "@typescript-eslint/parser": "^8.62.0", + "apexcharts": "~5.15.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.2", - "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-prettier": "^5.5.6", "jasmine-core": "^4.5.0", "jasmine-spec-reporter": "^7.0.0", "karma": "^6.4.1", diff --git a/webapp/frontend/src/app/core/config/app.config.ts b/webapp/frontend/src/app/core/config/app.config.ts index 2ad84d93d..b8cabb2b7 100644 --- a/webapp/frontend/src/app/core/config/app.config.ts +++ b/webapp/frontend/src/app/core/config/app.config.ts @@ -147,6 +147,7 @@ export interface AppConfig { report_enabled?: boolean; report_daily_enabled?: boolean; consumer_drive_profiles_enabled?: boolean; + consumer_drive_profiles_denylist?: string; report_daily_time?: string; report_weekly_enabled?: boolean; report_weekly_day?: number; @@ -226,6 +227,7 @@ export const appConfig: AppConfig = { report_enabled: false, report_daily_enabled: false, consumer_drive_profiles_enabled: true, + consumer_drive_profiles_denylist: '', report_daily_time: '08:00', report_weekly_enabled: false, report_weekly_day: 1, diff --git a/webapp/frontend/src/app/data/mock/settings/data.ts b/webapp/frontend/src/app/data/mock/settings/data.ts index 87cba426d..6d0cb103f 100644 --- a/webapp/frontend/src/app/data/mock/settings/data.ts +++ b/webapp/frontend/src/app/data/mock/settings/data.ts @@ -17,6 +17,7 @@ export const settings = { status_threshold: 3, repeat_notifications: true, consumer_drive_profiles_enabled: true, + consumer_drive_profiles_denylist: '', }, }, }; diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html index 140d4f8f2..c91377b37 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html @@ -182,6 +182,18 @@

Scrutiny Settings

+ @if (consumerDriveProfilesEnabled) { +
+ + Consumer Drive Profile Denylist + + + Comma-separated profile families that should never be applied. Matching drives fall back to generic ATA rules. Leave empty to allow all vetted profiles. + + +
+ } +
diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts index 22556e38f..5044ef31c 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts @@ -140,6 +140,7 @@ export class DashboardSettingsComponent implements OnInit { reportEnabled: boolean; reportDailyEnabled: boolean; consumerDriveProfilesEnabled: boolean; + consumerDriveProfilesDenylist: string; reportDailyTime: string; reportWeeklyEnabled: boolean; reportWeeklyDay: number; @@ -261,6 +262,7 @@ export class DashboardSettingsComponent implements OnInit { this.reportEnabled = config.metrics.report_enabled ?? false; this.reportDailyEnabled = config.metrics.report_daily_enabled ?? false; this.consumerDriveProfilesEnabled = config.metrics.consumer_drive_profiles_enabled ?? true; + this.consumerDriveProfilesDenylist = config.metrics.consumer_drive_profiles_denylist ?? ''; this.reportDailyTime = config.metrics.report_daily_time ?? '08:00'; this.reportWeeklyEnabled = config.metrics.report_weekly_enabled ?? false; this.reportWeeklyDay = config.metrics.report_weekly_day ?? 1; @@ -565,6 +567,7 @@ export class DashboardSettingsComponent implements OnInit { report_enabled: this.reportEnabled, report_daily_enabled: this.reportDailyEnabled, consumer_drive_profiles_enabled: this.consumerDriveProfilesEnabled, + consumer_drive_profiles_denylist: this.consumerDriveProfilesDenylist?.trim() ?? '', report_daily_time: this.reportDailyTime, report_weekly_enabled: this.reportWeeklyEnabled, report_weekly_day: this.reportWeeklyDay,