commit 024dcbceb0757ab7532b6bf0e76675ad307e11c6 Author: ARIA Date: Sat May 9 15:05:29 2026 +0200 nvcurve with some fixes and better limits diff --git a/.github/screenshots/curve-editor.png b/.github/screenshots/curve-editor.png new file mode 100644 index 0000000..6256aca Binary files /dev/null and b/.github/screenshots/curve-editor.png differ diff --git a/.github/screenshots/dashboard.png b/.github/screenshots/dashboard.png new file mode 100644 index 0000000..21640b1 Binary files /dev/null and b/.github/screenshots/dashboard.png differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37365ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ + +# Node +frontend/node_modules/ +node_modules/ + +# IDE/Misc +.vscode/ +.idea/ +/tmp/ +/dist/ +/build/ +*.egg-info/ +.claude \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..39cc1e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.5.0] - 2026-03-23 + +### Changed +- **CLI Architecture Simplification**: The CLI has been decoupled from the FastAPI server and now operates as a stateless direct-HAL hardware administration tool. It no longer relies on the server for data reading or offset writing. +- **Consistent Privileges**: All CLI commands that interact with the hardware now explicitly require root privileges. +- **Background Daemon**: Added a new lightweight Unix socket daemon (`nvcurve daemon`) to handle auto-loading profiles on boot and managing the server's lifecycle. +- **Global Arguments**: Fixed global argument parsing in the CLI to allow global flags (like `--gpu`) to be placed after subcommands. + +## [0.4.0] - 2026-03-17 + +### Added +- **Multi-GPU Support** *(experimental — untested on real multi-GPU hardware)*: The server now manages all detected NVIDIA GPUs simultaneously under a single process. Each GPU gets its own isolated state (write lock, monitor clients, curve clients, active profile). REST endpoints and WebSocket subscriptions accept a `gpu_index` parameter. A new `/api/gpus` endpoint enumerates all GPUs with name, index, UUID, and PCI bus ID. +- **GPU Selector in Web UI**: When multiple GPUs are present, the status bar shows a dropdown to switch the active GPU. Switching resets all pending edits, selection state, and live monitoring for the new target. +- **Default Profile**: Added the ability to designate a profile as the default — it is applied automatically on server startup. + - CLI: `nvcurve profile default ` to set, `nvcurve profile default --clear` to unset. + - The web UI shows a filled star on the default profile and lets you toggle it with a single click. + - The setting persists to `/etc/nvcurve/config.json` (created by `service install`). If the config file is absent, the setting is in-memory for the current session only. +- **Curve Flattening**: Selecting two or more points and clicking "Flatten to [anchor]" in the toolbar sets each selected point to a different offset, such that all land on the same effective frequency as the anchor point. The anchor is the last explicitly clicked point (highlighted with an amber halo on the graph); bulk selections (box, range, Ctrl+A) preserve the existing anchor. +- **Server-Optional Profile Commands**: `profile apply`, `profile list`, and `profile default` no longer require the server to be running. When the server is absent they fall back to direct HAL calls or config-file writes, escalating to root via `sudo` automatically — the same pattern already used by snapshot commands. +- **Automated Setup Check**: `nvcurve setup` runs a consolidated 4-step hardware compatibility check: NvAPI function probe → V/F curve baseline read → non-destructive write-verify → automatic state restore. The write-verify defaults to the last GPU-domain point (safe on all GPU generations); override with `--point` and `--delta`. Pass `--full-mask` if writes fail on older GPUs such as Pascal. + +### Changed +- **Profile CLI syntax**: Profile commands now take the profile name as a positional argument instead of `--name` (e.g. `nvcurve profile apply balanced` instead of `nvcurve profile apply --name balanced`). +- **Improved Diagnostics**: The `read --diag` engine now reports driver version, VRAM totals, power limits, raw clock offsets, memory offset ranges, and raw boost masks in addition to the NvAPI function probe. +- **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..897e4f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ekojsalim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d48cbd --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +
+ NVCurve Logo +

NVCurve

+

Linux NVIDIA GPU V/F Curve Editor & OC Tool

+
+ +--- + +NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control to Linux. It calls undocumented NvAPI functions directly via `libnvidia-api.so`, giving you precise frequency offsets control. A Python CLI handles scripting and headless use; a React web UI provides interactive curve editing, profile management, and live hardware monitoring. + +> [!WARNING] +> **This tool is experimental.** It has been verified on a range of NVIDIA GPUs and recent drivers, but compatibility is not guaranteed across all hardware and driver versions. The underlying NvAPI functions are undocumented and may change or disappear between driver releases. +> +> Read operations are generally safe. Write operations alter GPU operational parameters. **Follow the first-time setup steps below before applying any changes**, and proceed with caution. + +![NVCurve curve editor](.github/screenshots/curve-editor.png) + +## Features + +- **Per-Point Curve Editing**: Independently adjust the frequency offset for any voltage point on the GPU's V/F curve. +- **Curve Flattening**: Select multiple points and flatten them to a common frequency in one click using anchor-point targeting. +- **Live Monitoring**: Tracks GPU voltage, clock speed, temperature, and power draw via NvAPI and NVML. +- **Profile Management**: Save, apply, and set a default (auto-apply on startup) profile. Profile commands work with or without the server running. +- **Multi-GPU Support** *(experimental)*: Manage multiple NVIDIA GPUs simultaneously from a single server instance; switch between GPUs in the web UI. Untested on real multi-GPU hardware — feedback welcome. +- **Modern Web UI**: Interactive curve graph, point table, real-time monitoring dashboard, and GPU selector, all in the browser. + +## Prerequisites + +- **OS**: Linux +- **GPU**: NVIDIA GPU (tested on RTX 5090, Blackwell architecture) +- **Python**: 3.12+ +- **Privileges**: Root access is required for hardware interactions. The CLI will prompt for elevated privileges via `sudo` when needed. + +## Installation + +We recommend installing with [uv](https://docs.astral.sh/uv/getting-started/installation/): + +```bash +uv tool install nvcurve +``` + +*For frontend development, use `pnpm install` and `pnpm run dev` in the `frontend/` directory.* + +## Getting started + +### Step 1 — Verify hardware compatibility + +Before touching anything, confirm NvAPI is working correctly on your GPU and driver. **Do not skip this on an untested configuration.** + +```bash +nvcurve setup +``` + +This runs four checks in sequence: +1. **NvAPI function probe** — verifies all required functions resolve in the driver +2. **Curve read** — reads and displays your current V/F curve as a baseline +3. **Write-verify** — writes `+5 MHz` to the last GPU-domain point, reads it back, and confirms the driver accepted it and no other points were unexpectedly modified +4. **Restore** — automatically restores the snapshot from before the test write + +Only continue if `setup` reports **Compatible**. + +> Override the test point or delta with `nvcurve setup --point N --delta 5`. Pass `--full-mask` if writes fail on older GPUs (Pascal and earlier). + +### Step 2 — Launch the web UI + +With compatibility confirmed, the web UI is the primary interface for curve editing, monitoring, and profile management: + +```bash +nvcurve +``` + +This starts the backend server and opens the UI in your browser at `http://localhost:8042`. + +![NVCurve web UI dashboard](.github/screenshots/dashboard.png) + +To run the server in the background: + +```bash +nvcurve serve start --detach +nvcurve serve status +nvcurve serve stop +``` + +### Step 3 — (Optional) Install as a systemd service + +Register the nvcurve daemon as a systemd service so it starts automatically on boot: + +```bash +nvcurve service install +``` + +The daemon handles auto-loading GPU profiles on boot. The web server is separate and starts on demand (`nvcurve serve start`). To also have the web server start automatically on boot, pass `--auto-serve`: + +```bash +nvcurve service install --auto-serve +# optionally: --host 0.0.0.0 --port 8042 +``` + +This enables and starts the service immediately. Manage it with: + +```bash +nvcurve service start +nvcurve service stop +nvcurve service restart +nvcurve service status # shows daemon state + web server config +nvcurve service uninstall +``` + +To change the web server auto-start setting or address after install, use `service configure` — it updates the config and restarts the daemon in one step: + +```bash +sudo nvcurve service configure --auto-serve +sudo nvcurve service configure --no-auto-serve +sudo nvcurve service configure --host 0.0.0.0 --port 8042 +``` + +## Upgrading + +```bash +uv tool upgrade nvcurve +``` + +If you are running nvcurve as a systemd service, restart it afterwards to pick up the new version: + +```bash +nvcurve service restart +``` + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for a complete history of changes. + +## Web UI controls + +### Curve editor + +| Action | Result | +|---|---| +| Click point | Select (clears other selections) | +| Shift+click point | Add/remove point from selection | +| Ctrl/Cmd+A | Select all active points | +| Escape | Clear selection | +| Drag point | Stage a frequency offset edit | +| Drag point (multi-selection) | Moves all selected points together | +| Enter (single point selected) | Open inline input to type an exact offset | +| ↑ / ↓ arrow keys | Nudge selected point(s) ±1 MHz | +| Ctrl/Cmd + ↑ / ↓ | Nudge ±10 MHz | +| Tab / Shift+Tab | Step through points one by one | +| Shift+drag on background | Box select; draw a rubber-band rectangle | +| Drag on background | Pan the X axis | +| Alt+scroll | Zoom X axis around the cursor | + +### Point table + +| Action | Result | +|---|---| +| Click row | Select point | +| Ctrl/Cmd+click | Toggle point in/out of selection | +| Shift+click | Range select from last selected to clicked | +| Drag across rows | Range select by dragging | +| Select Before / Select After | Expand selection to all points before or after the currently selected one (appears when exactly one point is selected) | + +### Toolbar + +The **Global Offset** slider appears when all active points share a uniform delta. Dragging it stages that offset across every point simultaneously; equivalent to `nvcurve write --global` but interactive. + +## CLI reference + +The CLI is suited for scripting, headless systems, or quick one-off operations. All write commands support `--dry-run` to preview changes without applying them. + +### Reading + +```bash +nvcurve read # Condensed V/F curve +nvcurve read --full # All points +nvcurve read --json # JSON output +``` + +### Writing + +> [!NOTE] +> If you use LACT or similar tools, disable them first. Concurrent writes will overwrite each other. + +```bash +# Preview first +nvcurve write --global --delta 50 --dry-run +nvcurve write --point 80 --delta 100 --dry-run + +# Apply +nvcurve write --global --delta 50 +nvcurve write --point 80 --delta 100 +nvcurve write --range 70-90 --delta 75 +nvcurve write --reset # Reset all offsets to 0 +``` + +Snapshots are saved automatically before each write. Manual snapshot management: + +```bash +nvcurve snapshot save +nvcurve snapshot list +nvcurve snapshot restore +``` + +### Profiles + +Profile commands work whether or not the server is running. Apply and default operations escalate to root automatically when bypassing the server. + +```bash +nvcurve profile save balanced # Save current curve as a profile +nvcurve profile apply balanced # Apply a saved profile +nvcurve profile list # List profiles with active/default markers +nvcurve profile default balanced # Set profile to apply on server startup +nvcurve profile default --clear # Clear the startup default +nvcurve --gpu 1 profile default perf # Set default for a specific GPU (multi-GPU) +``` + +> [!NOTE] +> If you use LACT or similar tools and have a default profile set, disable the `lactd` service first — it applies its own curve on startup and will overwrite NVCurve's auto-applied profile: +> ```bash +> sudo systemctl disable --now lactd +> ``` + +### Diagnostics + +```bash +nvcurve read --diag # Probe all NvAPI functions +nvcurve inspect --point 80 # Raw buffer fields for a point +nvcurve inspect --range 78-82 +``` + +## Architecture + +NVCurve has two components: + +- **Python backend (`nvcurve/`)**: Talks directly to `libnvidia-api.so` (via ctypes) and `libnvidia-ml.so` to read and write hardware state. Exposes everything through a FastAPI REST + WebSocket server, keeping privileged operations isolated from the browser. +- **React frontend (`frontend/`)**: Runs in the browser and communicates with the backend over HTTP and WebSockets. Handles curve visualization, point editing, live monitoring, and profile management. + +## Disclaimer + +This software is provided as-is. The NvAPI functions it relies on are undocumented, unsupported officially by NVIDIA, and may change or break without notice between driver releases. NVCurve has been verified on a range of NVIDIA GPUs and recent drivers; behaviour on other hardware or driver versions is not guaranteed. Write operations alter GPU state. The authors accept no responsibility for hardware damage, system instability, or data loss resulting from the use of this software. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..4dcad1f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..deeef78 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + NVCurve + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e0c1c79 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4877 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "d3": "^7.9.0", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sonner": "^2.0.7", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@types/d3": "^7.4.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "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/@swc/core": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", + "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.33", + "@swc/core-darwin-x64": "1.15.33", + "@swc/core-linux-arm-gnueabihf": "1.15.33", + "@swc/core-linux-arm64-gnu": "1.15.33", + "@swc/core-linux-arm64-musl": "1.15.33", + "@swc/core-linux-ppc64-gnu": "1.15.33", + "@swc/core-linux-s390x-gnu": "1.15.33", + "@swc/core-linux-x64-gnu": "1.15.33", + "@swc/core-linux-x64-musl": "1.15.33", + "@swc/core-win32-arm64-msvc": "1.15.33", + "@swc/core-win32-ia32-msvc": "1.15.33", + "@swc/core-win32-x64-msvc": "1.15.33" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", + "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", + "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", + "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", + "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", + "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", + "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", + "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", + "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", + "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", + "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", + "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", + "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.3.tgz", + "integrity": "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "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.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", + "integrity": "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9c9191a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "d3": "^7.9.0", + "lucide-react": "^0.577.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sonner": "^2.0.7", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@types/d3": "^7.4.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..441648b --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3069 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + d3: + specifier: ^7.9.0 + version: 7.9.0 + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.14)(react@19.2.4) + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.4 + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react-swc': + specifier: ^4.2.2 + version: 4.2.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)) + eslint: + specifier: ^9.39.1 + version: 9.39.4(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.0 + version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@4.2.3': + resolution: {integrity: sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001777: + resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.307: + resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@swc/core-darwin-arm64@1.15.18': + optional: true + + '@swc/core-darwin-x64@1.15.18': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.18': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.18': + optional: true + + '@swc/core-linux-arm64-musl@1.15.18': + optional: true + + '@swc/core-linux-x64-gnu@1.15.18': + optional: true + + '@swc/core-linux-x64-musl@1.15.18': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.18': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.18': + optional: true + + '@swc/core-win32-x64-msvc@1.15.18': + optional: true + + '@swc/core@1.15.18': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1) + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + '@swc/core': 1.15.18 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@swc/helpers' + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001777 + electron-to-chromium: 1.5.307 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001777: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@7.2.0: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.307: {} + + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.1 + keyv: 4.5.4 + + flatted@3.4.1: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.577.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.36: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + + resolve-from@4.0.0: {} + + robust-predicates@3.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} + + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..7c32629 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@swc/core' + - esbuild diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..e0eccff --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..034a20a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,159 @@ +import { useGpu } from './hooks/useGpu'; +import { useCurve } from './hooks/useCurve'; +import { useMonitor } from './hooks/useMonitor'; +import { StatusBar } from './components/Monitor/StatusBar'; +import { LiveMonitor } from './components/Monitor/LiveMonitor'; +import { CurveEditor } from './components/CurveEditor/CurveEditor'; +import { PointTable } from './components/PointTable/PointTable'; +import { PerformancePanel } from './components/Limits/PerformancePanel'; +import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor'; +import { ProfilePanel } from './components/Profiles/ProfilePanel'; +import { api } from './api/client'; +import { useCurveStore } from './store/curveStore'; +import { Toaster } from 'sonner'; +import { Loader, ChevronDown } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; + +export default function App() { + const gpuInfo = useGpu(); + const { curve, wsStatus: curveWsStatus } = useCurve(); + const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor(); + const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore(); + + const [activeTab, setActiveTab] = useState<'curve' | 'performance'>('curve'); + const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu'); + const [isProfileOpen, setIsProfileOpen] = useState(false); + const profileRef = useRef(null); + + const currentVoltageMv = monitor?.voltage_mv ?? null; + + function handleRefresh() { + api.curve(selectedGpuIndex).then(setCurve).catch(console.error); + } + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (profileRef.current && !profileRef.current.contains(event.target as Node)) { + setIsProfileOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + + // Fetch initial active profile + api.profiles(selectedGpuIndex).then(data => setActiveProfile(data.active)).catch(console.error); + + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [selectedGpuIndex, setActiveProfile]); + + // Worst connection status wins + const wsStatus = + monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected' + ? 'disconnected' + : monitorWsStatus === 'connecting' || curveWsStatus === 'connecting' + ? 'connecting' + : 'connected'; + + return ( +
+ + + + {wsStatus !== 'connected' && ( +
+ + {wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'} +
+ )} + +
+ {/* Tab Header and Profile Selector */} +
+
+ + +
+
+ + {isProfileOpen && ( +
+ +
+ )} +
+
+ + {/* Main content area */} +
+ {activeTab === 'curve' ? ( + <> +
+
+ {curve ? ( + + ) : ( +
+ Loading curve… +
+ )} +
+ +
+ +
+
+ + {curve && ( +
+ p.domain === activeDomain)} + currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null} + readOnly={activeDomain === 'memory'} + /> +
+ )} + + ) : ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..b63f020 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,79 @@ +import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData } from '../types'; + +async function get(path: string, gpuIndex?: number): Promise { + const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; + const res = await fetch(url); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`GET ${url}: ${res.status} — ${text}`); + } + return res.json() as Promise; +} + +async function post(path: string, body?: unknown, gpuIndex?: number): Promise { + const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; + const res = await fetch(url, { + method: 'POST', + headers: body != null ? { 'Content-Type': 'application/json' } : {}, + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`POST ${url}: ${res.status} — ${text}`); + } + // Some endpoints return no body (204) + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) return res.json() as Promise; + return undefined as unknown as T; +} + +async function del(path: string, gpuIndex?: number): Promise { + const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`; + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`DELETE ${url}: ${res.status} — ${text}`); + } + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) return res.json() as Promise; + return undefined as unknown as T; +} + +export const api = { + gpus: () => get('/gpus'), + gpu: (gpuIndex: number) => get('/gpu', gpuIndex), + curve: (gpuIndex: number) => get('/curve', gpuIndex), + ranges: (gpuIndex: number) => get>('/ranges', gpuIndex), + voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex), + monitor: (gpuIndex: number) => get('/monitor', gpuIndex), + snapshots: (gpuIndex: number) => get('/snapshots', gpuIndex), + + /** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */ + writeDeltas: (deltas: Record, gpuIndex: number) => + post<{ ok: boolean; freq_warnings?: string[] }>('/curve/write', { deltas }, gpuIndex), + + /** Reset all frequency deltas to zero. */ + resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex), + + /** Get performance limits mapping */ + limits: (gpuIndex: number) => get('/limits', gpuIndex), + + /** Set performance limits */ + updateLimits: (updates: Partial, gpuIndex: number) => + post('/limits', updates, gpuIndex), + + /** Reset power limit and memory offset to hardware defaults */ + resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex), + + /** Profile Management */ + profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex), + saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex), + applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex), + deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`), + renameProfile: (oldName: string, newName: string) => + post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }), + + /** Server config */ + setAutoLoadProfile: (name: string | null, gpuIndex: number) => + post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }), +}; diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts new file mode 100644 index 0000000..d9f4f82 --- /dev/null +++ b/frontend/src/api/websocket.ts @@ -0,0 +1,61 @@ +type MessageHandler = (data: T) => void; +type StatusHandler = (status: 'connecting' | 'connected' | 'disconnected') => void; + +export interface WsHandle { + close: () => void; +} + +export function createWsConnection( + path: string, + onMessage: MessageHandler, + onStatus: StatusHandler, + gpuIndex: number = 0, +): WsHandle { + let ws: WebSocket | null = null; + let delay = 1000; + let stopped = false; + let timer: ReturnType | null = null; + + function connect() { + if (stopped) return; + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const url = `${protocol}://${location.host}${path}`; + onStatus('connecting'); + ws = new WebSocket(url); + + ws.onopen = () => { + delay = 1000; + onStatus('connected'); + ws?.send(JSON.stringify({ action: 'subscribe', gpu_index: gpuIndex })); + }; + + ws.onmessage = (e) => { + try { + onMessage(JSON.parse(e.data as string) as T); + } catch { + // ignore malformed + } + }; + + ws.onclose = () => { + if (stopped) return; + onStatus('disconnected'); + timer = setTimeout(connect, delay); + delay = Math.min(delay * 2, 8000); + }; + + ws.onerror = () => { + ws?.close(); + }; + } + + connect(); + + return { + close() { + stopped = true; + if (timer) clearTimeout(timer); + ws?.close(); + }, + }; +} diff --git a/frontend/src/components/CurveEditor/CurveEditor.tsx b/frontend/src/components/CurveEditor/CurveEditor.tsx new file mode 100644 index 0000000..ca0e854 --- /dev/null +++ b/frontend/src/components/CurveEditor/CurveEditor.tsx @@ -0,0 +1,975 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { Check, X, RotateCcw, RefreshCw } from 'lucide-react'; +import { scaleLinear } from 'd3'; +import type { VFPoint, CurveState } from '../../types'; +import { CurveTooltip } from './CurveTooltip'; +import { CurveToolbar } from './CurveToolbar'; +import { ConfirmDialog } from '../common/ConfirmDialog'; +import { voltExtent, refBaseMhz, detectClampedPoints } from '../../utils/curveHelpers'; +import { useCurveStore } from '../../store/curveStore'; + +interface Props { + curve: CurveState; + activeDomain: 'gpu' | 'memory'; + onDomainChange: (d: 'gpu' | 'memory') => void; + currentVoltageMv: number | null; + currentClockMhz: number | null; + onRefresh: () => void; +} + +const MARGIN = { top: 16, right: 24, bottom: 48, left: 64 }; + +/** Drag-tooltip info (shown while actively dragging a point) */ +interface DragInfo { + pointIndex: number; + /** Current pending delta kHz while dragging */ + currentDeltaKhz: number; + /** SVG inner coords of the point, for tooltip positioning */ + cx: number; + cy: number; +} + +/** Box-select rubber-band rect (SVG inner coords) */ +interface BoxRect { x0: number; y0: number; x1: number; y1: number } + +export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltageMv, currentClockMhz, onRefresh }: Props) { + const readOnly = activeDomain === 'memory'; + const [hoveredPoint, setHoveredPoint] = useState(null); + const [dragInfo, setDragInfo] = useState(null); + const [boxRect, setBoxRect] = useState(null); + const [inlineInput, setInlineInput] = useState<{ pointIndex: number; value: string } | null>(null); + const svgRef = useRef(null); + const containerRef = useRef(null); + const [svgCursor, setSvgCursor] = useState('default'); + + // ─── Dynamic SVG dimensions ────────────────────────────────────────────── + const [svgW, setSvgW] = useState(680); + const svgWRef = useRef(680); + svgWRef.current = svgW; + const INNER_W = svgW - MARGIN.left - MARGIN.right; + + const [svgH, setSvgH] = useState(460); + const svgHRef = useRef(460); + svgHRef.current = svgH; + const INNER_H = svgH - MARGIN.top - MARGIN.bottom; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const { width, height } = entries[0].contentRect; + const w = Math.round(width); + const h = Math.min(Math.round(height), 500, Math.round(window.innerHeight * 0.58)); + setSvgW(prev => prev === w ? prev : Math.max(300, w)); + setSvgH(prev => prev === h ? prev : Math.max(200, h)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Store + const { + pendingDeltas, + selectedPoints, + anchorPoint, + stageEdit, + stageMultiEdit, + selectPoint, + selectRange, + clearSelection, + effectiveMhz, + applyEdits, + discardEdits, + resetAllDeltas, + hasNegativeFreqWarning, + } = useCurveStore(); + + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [dialog, setDialog] = useState<'apply' | 'reset' | null>(null); + + async function handleApplyConfirm() { + setDialog(null); + setBusy(true); + setActionError(null); + try { + await applyEdits(onRefresh); + } catch (e) { + setActionError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + async function handleResetConfirm() { + setDialog(null); + setBusy(true); + setActionError(null); + try { + await resetAllDeltas(onRefresh); + } catch (e) { + setActionError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + const pts = useMemo( + () => curve.points.filter(p => p.domain === activeDomain), + [curve.points, activeDomain], + ); + const clampedPoints = useMemo(() => detectClampedPoints(pts), [pts]); + + // Clear selection and pending when switching domains + useEffect(() => { + clearSelection(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeDomain]); + + // ─── X-axis zoom / viewport ────────────────────────────────────────────── + // Use a slightly larger padding to space things out better + const [xViewport, setXViewport] = useState<[number, number]>(() => voltExtent(pts, 40)); + + // Reset viewport when the set of points changes (curve refresh) + const ptsKey = pts.map((p) => p.index).join(','); + useEffect(() => { + setXViewport(voltExtent(pts, 40)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ptsKey]); + + // Compute Y domain accounting for pending changes + const allEffective = pts.map((p) => effectiveMhz(p)); + const allBase = pts.map((p) => refBaseMhz(p)); + const freqMin = Math.min(...allBase, ...allEffective) - 50; + const freqMax = Math.max(...allBase, ...allEffective) + 50; + + const xScale = scaleLinear().domain(xViewport).range([0, INNER_W]); + const yScale = scaleLinear().domain([freqMin, freqMax]).range([INNER_H, 0]); + + // Keep scales in a ref so event handler closures always see the latest + const scalesRef = useRef({ xScale, yScale, xViewport, innerW: INNER_W }); + scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W }; + + const xTicks = xScale.ticks(8); + const yTicks = yScale.ticks(6); + + // ─── Zoom factor (for toolbar slider) ──────────────────────────────────── + const [fullExtentMin, fullExtentMax] = voltExtent(pts); + const fullWidth = (fullExtentMax - fullExtentMin) + 80; + const currentWidth = xViewport[1] - xViewport[0]; + const zoomFactor = Math.max(1, Math.min(10, fullWidth / currentWidth)); + + function handleZoomChange(factor: number) { + const pad = 40; + const newWidth = Math.max(20, fullWidth / factor); + const center = (xViewport[0] + xViewport[1]) / 2; + let newMin = center - newWidth / 2; + let newMax = center + newWidth / 2; + if (newMin < fullExtentMin - pad) { newMin = fullExtentMin - pad; newMax = newMin + newWidth; } + if (newMax > fullExtentMax + pad) { newMax = fullExtentMax + pad; newMin = newMax - newWidth; } + setXViewport([newMin, newMax]); + } + + // Polylines + const visiblePts = pts.filter((p) => { + const v = p.volt_mv; + return v >= xViewport[0] && v <= xViewport[1]; + }); + + const baseLine = visiblePts + .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`) + .join(' '); + + const effectiveLine = visiblePts + .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`) + .join(' '); + + const hasPending = pendingDeltas.size > 0; + const pendingLine = hasPending + ? visiblePts + .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`) + .join(' ') + : null; + + // ─── Coordinate helpers ────────────────────────────────────────────────── + function svgToContainer(svgX: number, svgY: number): { x: number; y: number } | null { + const svg = svgRef.current; + const container = containerRef.current; + if (!svg || !container) return null; + const svgRect = svg.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const scaleX = svgRect.width / svgWRef.current; + const scaleY = svgRect.height / svgHRef.current; + return { + x: svgRect.left - containerRect.left + (svgX + MARGIN.left) * scaleX, + y: svgRect.top - containerRect.top + (svgY + MARGIN.top) * scaleY, + }; + } + + function clientToInner(clientX: number, clientY: number): { x: number; y: number } | null { + const svg = svgRef.current; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + return { + x: (clientX - rect.left) * (svgWRef.current / rect.width) - MARGIN.left, + y: (clientY - rect.top) * (svgHRef.current / rect.height) - MARGIN.top, + }; + } + + // ─── SVG cursor ────────────────────────────────────────────────────────── + function handleSvgMouseMove(e: React.MouseEvent) { + if (isPanning.current) return; + const next = e.shiftKey ? 'crosshair' : 'default'; + setSvgCursor(c => c === next ? c : next); + } + + function handleSvgMouseLeave() { + if (!isPanning.current) setSvgCursor('default'); + } + + // ─── Point hover ───────────────────────────────────────────────────────── + const handleMouseEnter = useCallback((p: VFPoint) => { + setHoveredPoint(p); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredPoint(null); + }, []); + + // ─── Drag state (refs — survive renders without triggering effects) ─────── + const dragState = useRef<{ + active: boolean; + pointIndex: number; + startY: number; + startDeltaKhz: number; + pointInitialDeltas: Map; + } | null>(null); + /** True if the mouse moved meaningfully during the current mousedown */ + const dragMoved = useRef(false); + + // ─── Box select refs ───────────────────────────────────────────────────── + const boxStartRef = useRef<{ x: number; y: number } | null>(null); + const isBoxSelecting = useRef(false); + + // ─── Pan refs ───────────────────────────────────────────────────────────── + const panState = useRef<{ startX: number; startY: number; startViewport: [number, number] } | null>(null); + const isPanning = useRef(false); + + // ─── Point mousedown → start drag ──────────────────────────────────────── + function handlePointMouseDown(e: React.MouseEvent, p: VFPoint) { + if (readOnly) return; + containerRef.current?.focus(); + e.stopPropagation(); + e.preventDefault(); + + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + + const currentDelta = pendingDeltas.has(p.index) + ? pendingDeltas.get(p.index)! + : p.delta_khz; + + const initialDeltas = new Map(); + + if (selectedPoints.has(p.index) && selectedPoints.size > 1) { + // Feature: Dragging a point in a multi-selection moves the whole selection + for (const index of selectedPoints) { + const pt = pts.find(x => x.index === index); + if (pt) { + initialDeltas.set(index, pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz); + } + } + } else { + // Default: Just drag this point + initialDeltas.set(p.index, currentDelta); + } + + dragState.current = { + active: true, + pointIndex: p.index, + startY: inner.y, + startDeltaKhz: currentDelta, + pointInitialDeltas: initialDeltas + }; + dragMoved.current = false; + } + + // ─── SVG background mousedown → pan OR shift+drag = box select ─────────── + function handleSvgMouseDown(e: React.MouseEvent) { + containerRef.current?.focus(); + if (e.button !== 0) return; + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + + if (e.shiftKey) { + // Shift+drag = box select + isBoxSelecting.current = true; + boxStartRef.current = inner; + setBoxRect({ x0: inner.x, y0: inner.y, x1: inner.x, y1: inner.y }); + } else { + // Plain drag = pan + isPanning.current = true; + setSvgCursor('grabbing'); + panState.current = { startX: inner.x, startY: inner.y, startViewport: [...scalesRef.current.xViewport] as [number, number] }; + } + } + + // ─── Scroll wheel → zoom x-axis around cursor (Alt + scroll) ──────────── + function handleWheel(e: React.WheelEvent) { + if (!e.altKey) return; // plain scroll → let page scroll normally + e.preventDefault(); + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + + const { xScale: sx, xViewport: vp } = scalesRef.current; + const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // scroll up = zoom in + const mouseVolt = sx.invert(inner.x); + const [vpMin, vpMax] = vp; + const currentWidth = vpMax - vpMin; + const newWidth = Math.max(20, Math.min(voltExtent(pts)[1] - voltExtent(pts)[0] + 40, currentWidth / zoomFactor)); + const anchorFrac = (mouseVolt - vpMin) / currentWidth; + let newMin = mouseVolt - anchorFrac * newWidth; + let newMax = mouseVolt + (1 - anchorFrac) * newWidth; + // Clamp to full extent + const [fullMin, fullMax] = voltExtent(pts); + const pad = 20; + if (newMin < fullMin - pad) { newMax += (fullMin - pad - newMin); newMin = fullMin - pad; } + if (newMax > fullMax + pad) { newMin -= (newMax - fullMax - pad); newMax = fullMax + pad; } + setXViewport([newMin, newMax]); + } + + // ─── Global mousemove + mouseup ────────────────────────────────────────── + useEffect(() => { + function onMove(e: MouseEvent) { + // --- Point drag --- + if (dragState.current?.active) { + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + const ds = dragState.current; + const dyPx = inner.y - ds.startY; + + // Consider moved if more than 3px + if (Math.abs(dyPx) > 3) dragMoved.current = true; + + const { yScale: ys } = scalesRef.current; + const [yRangeBottom, yRangeTop] = ys.range() as [number, number]; + const [yDomainBottom, yDomainTop] = ys.domain() as [number, number]; + const pxPerMhz = (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom); + const deltaMhz = -dyPx / pxPerMhz; + + // Calculate the absolute delta for the primary dragged point + const primaryNewDeltaKhz = Math.round(ds.startDeltaKhz + deltaMhz * 1000); + const primaryClamped = Math.max(-1000_000, Math.min(1000_000, primaryNewDeltaKhz)); + + // Difference to apply to all other points in the selection + const validDeltaDiff = primaryClamped - ds.startDeltaKhz; + + if (ds.pointInitialDeltas.size > 1) { + const edits = new Map(); + ds.pointInitialDeltas.forEach((initialDelta, index) => { + const newDelta = initialDelta + validDeltaDiff; + const clampedNewDelta = Math.max(-1000_000, Math.min(1000_000, newDelta)); + edits.set(index, clampedNewDelta); + }); + stageMultiEdit(edits); + } else { + stageEdit(ds.pointIndex, primaryClamped); + } + + // Update drag tooltip info + const point = pts.find((p) => p.index === ds.pointIndex); + if (point) { + const cx = scalesRef.current.xScale(point.volt_mv); + const deltaChange = primaryClamped - point.delta_khz; + const cy = scalesRef.current.yScale(point.freq_mhz + deltaChange / 1000); + setDragInfo({ pointIndex: ds.pointIndex, currentDeltaKhz: primaryClamped, cx, cy }); + } + return; + } + + // --- Pan --- + if (isPanning.current && panState.current) { + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + const ps = panState.current; + // How many volts does 1 SVG-inner-px correspond to? + const voltPerPx = (ps.startViewport[1] - ps.startViewport[0]) / scalesRef.current.innerW; + const dxVolt = (inner.x - ps.startX) * voltPerPx; + const [fullMin, fullMax] = voltExtent(pts); + const pad = 20; + const vpWidth = ps.startViewport[1] - ps.startViewport[0]; + let newMin = ps.startViewport[0] - dxVolt; + let newMax = ps.startViewport[1] - dxVolt; + // Clamp so we don't pan completely outside + if (newMin < fullMin - pad) { newMin = fullMin - pad; newMax = newMin + vpWidth; } + if (newMax > fullMax + pad) { newMax = fullMax + pad; newMin = newMax - vpWidth; } + setXViewport([newMin, newMax]); + return; + } + + // --- Box select --- + if (isBoxSelecting.current && boxStartRef.current) { + const inner = clientToInner(e.clientX, e.clientY); + if (!inner) return; + setBoxRect({ + x0: boxStartRef.current.x, + y0: boxStartRef.current.y, + x1: inner.x, + y1: inner.y, + }); + } + } + + function onUp(e: MouseEvent) { + // --- End drag --- + if (dragState.current?.active) { + dragState.current = null; + setDragInfo(null); + return; + } + + // --- End pan --- + if (isPanning.current) { + isPanning.current = false; + setSvgCursor('default'); + if (panState.current) { + const inner = clientToInner(e.clientX, e.clientY); + if (inner) { + const dx = Math.abs(inner.x - panState.current.startX); + const dy = Math.abs(inner.y - panState.current.startY); + if (dx < 3 && dy < 3) { + // It was just a click on the background, not a pan, so clear selection + clearSelection(); + } + } + } + panState.current = null; + return; + } + + // --- End box select --- + if (isBoxSelecting.current && boxStartRef.current) { + isBoxSelecting.current = false; + const inner = clientToInner(e.clientX, e.clientY); + if (inner) { + const finalRect = { + x0: boxStartRef.current.x, + y0: boxStartRef.current.y, + x1: inner.x, + y1: inner.y, + }; + const minX = Math.min(finalRect.x0, finalRect.x1); + const maxX = Math.max(finalRect.x0, finalRect.x1); + const minY = Math.min(finalRect.y0, finalRect.y1); + const maxY = Math.max(finalRect.y0, finalRect.y1); + const sizeTrivial = Math.abs(finalRect.x1 - finalRect.x0) < 4 + && Math.abs(finalRect.y1 - finalRect.y0) < 4; + + if (!sizeTrivial) { + const { xScale: sx, yScale: ys } = scalesRef.current; + const selected = pts + .filter((p) => { + const cx = sx(p.volt_mv); + const cy = ys(effectiveMhz(p)); + return cx >= minX && cx <= maxX && cy >= minY && cy <= maxY; + }) + .map((p) => p.index); + selectRange(selected); + } else { + clearSelection(); + } + } + boxStartRef.current = null; + setBoxRect(null); + } + } + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + // Only re-register when structural deps change; scales are accessed via scalesRef + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pts, pendingDeltas, stageEdit, selectRange, clearSelection, effectiveMhz]); + + return ( +
+ {/* Header: title + domain toggle + action buttons */} +
+
+ V/F Curve + {/* Domain toggle */} +
+ {([{label: 'GPU', value: 'gpu'}, {label: 'Memory', value: 'memory'}] as const).map((d) => ( + + ))} +
+ {readOnly && ( + read-only + )} +
+
+ {!readOnly && hasPending && ( + + {pendingDeltas.size} pending + + )} + {!readOnly && ( + <> + + + + + )} + +
+
+ + {/* Banners */} + {hasNegativeFreqWarning() && ( +
+ ⚠ One or more pending deltas would produce a negative effective frequency. The driver will clamp to 0 MHz. +
+ )} + {actionError && ( +
+ ⚠ {actionError} + +
+ )} + + {/* Confirm dialogs */} + {dialog === 'apply' && ( + setDialog(null)} + /> + )} + {dialog === 'reset' && ( + setDialog(null)} + /> + )} + +
+ setXViewport(voltExtent(pts))} + isZoomed={Math.abs((xViewport[1] - xViewport[0]) - (voltExtent(pts)[1] - voltExtent(pts)[0])) > 5} + readOnly={readOnly} + zoomFactor={zoomFactor} + onZoomChange={handleZoomChange} + /> +
+ + {/* Need tabIndex=0 to capture keyboard events */} +
{ + if (inlineInput) { + if (e.key === 'Escape') setInlineInput(null); + return; // Let the input handle it + } + + if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + selectRange(pts.map(p => p.index)); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + clearSelection(); + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + if (pts.length === 0) return; + const currentSelected = Array.from(selectedPoints); + if (currentSelected.length === 0) { + selectPoint(pts[0].index); + } else { + const lastSelected = e.shiftKey ? Math.min(...currentSelected) : Math.max(...currentSelected); + const idx = pts.findIndex(p => p.index === lastSelected); + if (idx >= 0) { + let nextIdx = e.shiftKey ? idx - 1 : idx + 1; + if (nextIdx < 0) nextIdx = pts.length - 1; + if (nextIdx >= pts.length) nextIdx = 0; + selectPoint(pts[nextIdx].index); + } + } + return; + } + + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + if (selectedPoints.size === 0) return; + const changeMhz = e.key === 'ArrowUp' ? 1 : -1; + const multiplier = (e.ctrlKey || e.metaKey) ? 10 : 1; + const changeKhz = changeMhz * multiplier * 1000; + + const edits = new Map(); + for (const idx of selectedPoints) { + const p = pts.find(pt => pt.index === idx); + if (p) { + const current = pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz; + const clamped = Math.max(-500_000, Math.min(500_000, current + changeKhz)); + edits.set(idx, clamped); + } + } + stageMultiEdit(edits); + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + if (selectedPoints.size === 1) { + const idx = Array.from(selectedPoints)[0]; + const p = pts.find(pt => pt.index === idx); + if (p) { + const initVal = (pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz) / 1000; + setInlineInput({ pointIndex: idx, value: initVal.toFixed(1) }); + // We'll focus the input in an effect + } + } + return; + } + }} + > + + + + + + + + + {/* Grid */} + {xTicks.map((t) => ( + + ))} + {yTicks.map((t) => ( + + ))} + + {/* X axis */} + {xTicks.map((t) => ( + + + {t.toFixed(0)} + + ))} + + {/* Y axis */} + {yTicks.map((t) => ( + + + {t.toFixed(0)} + + ))} + + {/* Axis labels */} + Voltage (mV) + Frequency (MHz) + + {/* Border */} + + + + {/* Base curve */} + + + {/* Confirmed effective curve */} + + + {/* Pending effective curve */} + {pendingLine && ( + + )} + + {/* Current voltage / clock crosshairs — GPU domain only */} + {!readOnly && currentVoltageMv != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && ( + + )} + {!readOnly && currentClockMhz != null && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( + + )} + {!readOnly && currentVoltageMv != null && currentClockMhz != null && + xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && + yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( + + )} + + {pts.map((p) => { + if (p.volt_mv < xViewport[0] - 5 || p.volt_mv > xViewport[1] + 5) return null; + + const cx = xScale(p.volt_mv); + /** Confirmed position (hardware state — VFP effective frequency) */ + const confirmedCy = yScale(p.freq_mhz); + /** Staged/pending position (what will be applied) */ + const pendingCy = yScale(effectiveMhz(p)); + const hasPendingEdit = pendingDeltas.has(p.index); + /** + * The "main" interactive circle sits at the pending position when + * there's a staged edit — that's the point the user is working with. + * Otherwise it sits at the confirmed position. + */ + const mainCy = hasPendingEdit ? pendingCy : confirmedCy; + const ghostCy = hasPendingEdit ? confirmedCy : null; // dim ring showing where it was + + const isHovered = hoveredPoint?.index === p.index; + const isSelected = selectedPoints.has(p.index); + const isDragging = dragInfo?.pointIndex === p.index; + const isAnchor = isSelected && selectedPoints.size >= 2 && p.index === anchorPoint; + + let fill = readOnly ? '#6366f1' : '#34d399'; + if (!readOnly && hasPendingEdit && !isSelected) fill = '#22d3ee'; + if (!readOnly && isSelected) fill = '#22d3ee'; + + // Tweaked radius for less bloated flat regions + const r = isDragging ? 5.5 : isHovered || isSelected || hasPendingEdit ? 4.5 : 2.5; + + return ( + + {/* Anchor ring — amber outer halo marking the flatten reference point */} + {isAnchor && ( + + )} + + {/* Dim ring at confirmed position (only visible when there's a pending edit) */} + {!readOnly && ghostCy !== null && Math.abs(ghostCy - mainCy) > 0.5 && ( + + )} + + {/* Drop-line from confirmed → pending while dragging */} + {!readOnly && isDragging && ghostCy !== null && Math.abs(ghostCy - mainCy) > 1 && ( + + )} + + {/* Main circle */} + handleMouseEnter(p)} + onMouseLeave={handleMouseLeave} + onMouseDown={readOnly ? undefined : (e) => handlePointMouseDown(e, p)} + onClick={readOnly ? undefined : (e) => { + if (dragMoved.current) return; + selectPoint(p.index, e.shiftKey); + }} + /> + + ); + })} + + + {/* Box-select rubber band */} + {boxRect && ( + + )} + + + + {/* Inline Input Overlay */} + {inlineInput && (() => { + const pt = pts.find(p => p.index === inlineInput.pointIndex); + if (!pt) return null; + const cx = scalesRef.current.xScale(pt.volt_mv); + const currentDelta = pendingDeltas.has(pt.index) ? pendingDeltas.get(pt.index)! : pt.delta_khz; + const deltaChange = currentDelta - pt.delta_khz; + const cy = scalesRef.current.yScale(pt.freq_mhz + deltaChange / 1000); + const pos = svgToContainer(cx, cy); + if (!pos) return null; + return ( +
+ setInlineInput({ ...inlineInput, value: e.target.value })} + onBlur={() => setInlineInput(null)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const mhz = parseFloat(inlineInput.value); + if (!isNaN(mhz) && isFinite(mhz)) { + const clamped = Math.max(-500_000, Math.min(500_000, Math.round(mhz * 1000))); + stageEdit(pt.index, clamped); + } + setInlineInput(null); + // Refocus the container + containerRef.current?.focus(); + } + if (e.key === 'Escape') { + setInlineInput(null); + containerRef.current?.focus(); + } + }} + className="w-20 bg-zinc-800 text-cyan-300 rounded px-1.5 py-0.5 border-2 border-cyan-500 shadow-lg outline-none text-xs font-mono text-center" + /> +
+ ); + })()} + + {/* Hover / Active tooltip */} + {(() => { + if (dragInfo) return null; + let tooltipPt = hoveredPoint; + if (!tooltipPt && selectedPoints.size === 1) { + tooltipPt = pts.find(p => p.index === Array.from(selectedPoints)[0]) || null; + } + if (!tooltipPt) return null; + + return ( + + ); + })()} + + {/* Drag tooltip — shown while actively dragging */} + {dragInfo && (() => { + const point = pts.find((p) => p.index === dragInfo.pointIndex); + if (!point) return null; + const deltaMhz = dragInfo.currentDeltaKhz / 1000; + const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz; + const effMhz = point.freq_mhz + deltaChange / 1000; + return ( +
+
+ ↕ Point {point.index} + {point.volt_mv.toFixed(0)} mV +
+
0 ? 'text-cyan-400' : deltaMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}`}> + Δ {deltaMhz > 0 ? '+' : ''}{deltaMhz.toFixed(1)} MHz +
+
+ → {effMhz.toFixed(0)} MHz +
+
+ ); + })()} +
+
+ ); +} diff --git a/frontend/src/components/CurveEditor/CurveToolbar.tsx b/frontend/src/components/CurveEditor/CurveToolbar.tsx new file mode 100644 index 0000000..d169ffb --- /dev/null +++ b/frontend/src/components/CurveEditor/CurveToolbar.tsx @@ -0,0 +1,130 @@ +import { useState, useMemo, useEffect } from 'react'; +import { ZoomIn, RotateCcw, Minus } from 'lucide-react'; +import { useCurveStore } from '../../store/curveStore'; +import type { VFPoint } from '../../types'; + +interface Props { + /** All curve points — used by global offset slider */ + activePts: VFPoint[]; + /** Called to reset the x-axis zoom to default */ + onResetZoom: () => void; + /** True if the chart is currently zoomed in */ + isZoomed: boolean; + /** True when viewing a read-only domain (memory) */ + readOnly?: boolean; + /** Current zoom factor (1 = no zoom) */ + zoomFactor: number; + /** Called when user changes zoom via slider */ + onZoomChange: (factor: number) => void; +} + +export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) { + const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore(); + + const [offsetMhz, setOffsetMhz] = useState(0); + + const uniformDeltaMhz = useMemo(() => { + if (activePts.length === 0) return 0; + const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz; + const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD); + return uniform ? firstD / 1000 : null; + }, [activePts, pendingDeltas]); + + useEffect(() => { + if (uniformDeltaMhz !== null) { + setOffsetMhz(uniformDeltaMhz); + } + }, [uniformDeltaMhz]); + + function handleOffsetChange(mhz: number) { + setOffsetMhz(mhz); + stageRangeEdit(activePts, Math.round(mhz * 1000)); + } + + return ( +
+ {/* Zoom control */} +
+ + onZoomChange(Number(e.target.value))} + className="w-20 h-1 cursor-pointer accent-cyan-400" + /> + + {zoomFactor.toFixed(1)}× + + {isZoomed && ( + + )} +
+ + {/* Divider */} + + + {/* Global offset slider — GPU only */} + {!readOnly && uniformDeltaMhz !== null && ( +
+ Global Offset + handleOffsetChange(Number(e.target.value))} + className="w-32 accent-cyan-400" + title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`} + /> + 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500', + ].join(' ')} + > + {offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz + +
+ )} + + {/* Flatten — visible when 2+ points are selected */} + {!readOnly && selectedPoints.size >= 2 && (() => { + const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) + ? anchorPoint + : Math.min(...selectedPoints); + const anchorDelta = + pendingDeltas.get(anchor) ?? + curve?.points.find(p => p.index === anchor)?.delta_khz ?? + 0; + const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`; + return ( + + ); + })()} + + {/* Legend — right-aligned */} +
+ effective + pending + current +
+
+ ); +} diff --git a/frontend/src/components/CurveEditor/CurveTooltip.tsx b/frontend/src/components/CurveEditor/CurveTooltip.tsx new file mode 100644 index 0000000..6d4e752 --- /dev/null +++ b/frontend/src/components/CurveEditor/CurveTooltip.tsx @@ -0,0 +1,70 @@ +import { fmt } from '../../utils/units'; +import type { VFPoint } from '../../types'; + +interface Props { + point: VFPoint; + /** Pending delta in kHz (from store), if any */ + pendingDeltaKhz?: number; + /** True if this point is being held up by monotonicity enforcement */ + isClamped?: boolean; +} + +/** + * HTML tooltip rendered as an absolutely-positioned overlay on top of the + * SVG so it's never clipped by the SVG viewport. + */ +export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) { + const hasPending = pendingDeltaKhz !== undefined; + const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0; + const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0; + const pendingEffMhz = hasPending ? point.freq_mhz + deltaChange / 1000 : null; + + return ( +
+
Point {point.index}
+
+ Volt: {fmt.mv(point.volt_mv, 1)} +
+
+ Offset: + 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-400'}> + {point.delta_khz > 0 ? '+' : ''}{fmt.mhz(point.delta_mhz, 1)} + +
+
+ Eff.: {fmt.mhz(point.freq_mhz, 0)} + {isClamped && ⇡} +
+ {isClamped && ( +
+ Clamped by lower-voltage point +
+ )} + {hasPending && ( + <> +
+
+ Pending: + 0 ? 'text-cyan-400' : pendingMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}> + {pendingMhz > 0 ? '+' : ''}{pendingMhz.toFixed(1)} MHz + +
+
+ → Eff.: {fmt.mhz(pendingEffMhz, 0)} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/Limits/PerformancePanel.tsx b/frontend/src/components/Limits/PerformancePanel.tsx new file mode 100644 index 0000000..61c3b7d --- /dev/null +++ b/frontend/src/components/Limits/PerformancePanel.tsx @@ -0,0 +1,239 @@ +import { useState, useEffect } from 'react'; +import { Check, X, RotateCcw } from 'lucide-react'; +import { api } from '../../api/client'; +import { useCurveStore } from '../../store/curveStore'; +import type { LimitsState } from '../../types'; +import { toast } from 'sonner'; +import { ConfirmDialog } from '../common/ConfirmDialog'; + +type Pending = Pick; + +export function PerformancePanel() { + const { selectedGpuIndex } = useCurveStore(); + const [limits, setLimits] = useState(null); + const [pending, setPending] = useState>({}); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmApply, setConfirmApply] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + + async function fetchLimits() { + try { + setLoading(true); + setLimits(await api.limits(selectedGpuIndex)); + } catch { + toast.error('Failed to load performance limits'); + } finally { + setLoading(false); + } + } + + useEffect(() => { fetchLimits(); }, [selectedGpuIndex]); + + async function handleApply() { + setBusy(true); + setError(null); + try { + await api.updateLimits(pending, selectedGpuIndex); + useCurveStore.getState().setActiveProfile(null); + setPending({}); + setConfirmApply(false); + await fetchLimits(); + toast.success('Performance limits applied'); + } catch (e: any) { + setError(e.message ?? String(e)); + setConfirmApply(false); + } finally { + setBusy(false); + } + } + + async function handleReset() { + setBusy(true); + setError(null); + try { + await api.resetLimits(selectedGpuIndex); + useCurveStore.getState().setActiveProfile(null); + setPending({}); + setConfirmReset(false); + await fetchLimits(); + toast.success('Performance limits reset to defaults'); + } catch (e: any) { + setError(e.message ?? String(e)); + setConfirmReset(false); + } finally { + setBusy(false); + } + } + + if (loading && !limits) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!limits) return null; + + const hasPending = Object.keys(pending).length > 0; + + // Power + const pwrVal = pending.power_limit_w ?? limits.power_limit_w ?? 0; + const pwrMin = limits.min_power_limit_w ?? 100; + const pwrMax = limits.max_power_limit_w ?? 600; + + // Memory offset + const memVal = pending.mem_offset_mhz ?? limits.mem_offset_mhz ?? 0; + const memMin = limits.min_mem_offset_mhz ?? -2000; + const memMax = Math.min(limits.max_mem_offset_mhz ?? 3000, 3000); + + return ( + <> +
+ + {/* ── Header ─────────────────────────────────────────────────────── */} +
+ Performance + + {hasPending && ( + + {Object.keys(pending).length} pending + + )} + +
+ + + +
+
+ + {/* ── Error banner ────────────────────────────────────────────────── */} + {error && ( +
+ ⚠ {error} + +
+ )} + +
+ + {/* ── Board Power Limit ─────────────────────────────────────────── */} +
+
+ Board Power Limit +
+ { + const v = parseInt(e.target.value); + if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v })); + }} + className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" + /> + W +
+
+
+ {pwrMin} + setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))} + className="flex-1 accent-cyan-400 h-1 cursor-pointer" + /> + {pwrMax} +
+
+ + {/* ── Memory Clock Offset ───────────────────────────────────────── */} +
+
+ Memory Clock Offset +
+ { + const v = parseInt(e.target.value); + if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v })); + }} + className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" + /> + MHz +
+
+
+ {memMin} + setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))} + className="flex-1 accent-cyan-400 h-1 cursor-pointer" + /> + +{memMax} +
+
+ +
+
+ + {confirmApply && ( + setConfirmApply(false)} + /> + )} + {confirmReset && ( + setConfirmReset(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/Monitor/GaugeCard.tsx b/frontend/src/components/Monitor/GaugeCard.tsx new file mode 100644 index 0000000..595b234 --- /dev/null +++ b/frontend/src/components/Monitor/GaugeCard.tsx @@ -0,0 +1,52 @@ +interface Props { + label: string; + value: string; + history: number[]; + unit?: string; + color?: string; // tailwind color class for the sparkline stroke + max?: number; +} + +const SPARKLINE_H = 32; +const SPARKLINE_W = 120; + +function Sparkline({ data, color = '#a78bfa', max }: { data: number[]; color?: string; max?: number }) { + const points = data.length >= 2 ? (() => { + const dataMax = max ?? Math.max(...data, 1); + const dataMin = Math.min(...data, 0); + const range = dataMax - dataMin || 1; + return data.map((v, i) => { + const x = (i / (data.length - 1)) * SPARKLINE_W; + const y = SPARKLINE_H - ((v - dataMin) / range) * SPARKLINE_H; + return `${x},${y}`; + }).join(' '); + })() : null; + + return ( + + {points && ( + + )} + + ); +} + +export function GaugeCard({ label, value, history, color = '#a78bfa', max }: Props) { + return ( +
+
{label}
+
{value}
+
+ +
+
+ ); +} diff --git a/frontend/src/components/Monitor/LiveMonitor.tsx b/frontend/src/components/Monitor/LiveMonitor.tsx new file mode 100644 index 0000000..7724a1f --- /dev/null +++ b/frontend/src/components/Monitor/LiveMonitor.tsx @@ -0,0 +1,66 @@ +import { GaugeCard } from './GaugeCard'; +import { fmt } from '../../utils/units'; +import type { MonitoringSample } from '../../types'; + +interface Props { + monitor: MonitoringSample | null; + history: MonitoringSample[]; +} + +function pluck( + history: MonitoringSample[], + key: K, +): number[] { + return history.map((s) => (s[key] as number | null) ?? 0); +} + +export function LiveMonitor({ monitor, history }: Props) { + return ( +
+
+
+ Live Monitor +
+
+ + + + +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/Monitor/PerformanceMonitor.tsx b/frontend/src/components/Monitor/PerformanceMonitor.tsx new file mode 100644 index 0000000..37319ea --- /dev/null +++ b/frontend/src/components/Monitor/PerformanceMonitor.tsx @@ -0,0 +1,56 @@ +import { GaugeCard } from './GaugeCard'; +import { fmt } from '../../utils/units'; +import type { MonitoringSample } from '../../types'; + +interface Props { + monitor: MonitoringSample | null; + history: MonitoringSample[]; +} + +function pluck( + history: MonitoringSample[], + key: K, +): number[] { + return history.map((s) => (s[key] as number | null) ?? 0); +} + +export function PerformanceMonitor({ monitor, history }: Props) { + const memUsed = monitor?.mem_used_mib ?? null; + const memTotal = monitor?.mem_total_mib ?? null; + const memLabel = memUsed != null && memTotal != null + ? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB` + : '—'; + + return ( +
+
+
+ Live Monitor +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/Monitor/StatusBar.tsx b/frontend/src/components/Monitor/StatusBar.tsx new file mode 100644 index 0000000..bbcb2d9 --- /dev/null +++ b/frontend/src/components/Monitor/StatusBar.tsx @@ -0,0 +1,128 @@ +import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react'; +import type { GpuInfo, MonitoringSample } from '../../types'; +import { fmt } from '../../utils/units'; +import { useCurveStore } from '../../store/curveStore'; +import { useState, useRef, useEffect } from 'react'; + +interface Props { + gpuInfo: GpuInfo | null; + wsStatus: 'connecting' | 'connected' | 'disconnected'; + monitor: MonitoringSample | null; +} + +const statusIcon = { + connected: , + connecting: , + disconnected: , +}; + +const statusText = { + connected: 'Connected', + connecting: 'Connecting…', + disconnected: 'Disconnected', +}; + +export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) { + const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+
+ {/* Left: GPU name */} +
+ NVCurve + + NVCurve + +
+ + {availableGpus.length > 1 ? ( +
+ + + {isOpen && ( +
+ {availableGpus.map((gpu) => ( + + ))} +
+ )} +
+ ) : ( + <> + + + {gpuInfo?.name ?? 'No Device'} + + + )} + + {gpuInfo?.driver_version && ( +
+ + {gpuInfo.driver_version} +
+ )} +
+ + {/* Centre: live stats */} +
+ + + + + +
+ + {/* Right: connection status */} +
+ {statusIcon[wsStatus]} + {statusText[wsStatus]} +
+
+
+ ); +} + +function StatPill({ label, value, color }: { label: string; value: string; color: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/frontend/src/components/PointTable/PointRow.tsx b/frontend/src/components/PointTable/PointRow.tsx new file mode 100644 index 0000000..9c2179d --- /dev/null +++ b/frontend/src/components/PointTable/PointRow.tsx @@ -0,0 +1,124 @@ +import { useState, useRef, useEffect } from 'react'; +import { fmt } from '../../utils/units'; +import type { VFPoint } from '../../types'; +import { useCurveStore } from '../../store/curveStore'; + +interface Props { + point: VFPoint; + isCurrent: boolean; + isSelected: boolean; + isClamped: boolean; + pendingDeltaKhz?: number; + shouldAutoScroll?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseEnter?: () => void; +} + +export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) { + const { stageEdit } = useCurveStore(); + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + const trRef = useRef(null); + + useEffect(() => { + if (shouldAutoScroll && trRef.current) { + // @ts-expect-error: the typing seems to not include the valid 'container' option + trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' }); + } + }, [shouldAutoScroll]); + + const hasPending = pendingDeltaKhz !== undefined; + const displayDeltaMhz = hasPending ? pendingDeltaKhz / 1000 : point.delta_mhz; + const displayDeltaKhz = hasPending ? pendingDeltaKhz : point.delta_khz; + // Effective = current effective + delta change + const deltaChange = hasPending ? pendingDeltaKhz - point.delta_khz : 0; + const displayEffMhz = point.freq_mhz + deltaChange / 1000; + + const deltaColor = hasPending + ? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400' + : point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500'; + + function startEdit() { + setInputValue((displayDeltaMhz).toFixed(1)); + setEditing(true); + setTimeout(() => { + inputRef.current?.select(); + }, 0); + } + + function commitEdit() { + const mhz = parseFloat(inputValue); + if (!isNaN(mhz) && isFinite(mhz)) { + const clamped = Math.max(-1000, Math.min(1000, mhz)); + stageEdit(point.index, Math.round(clamped * 1000)); + } + setEditing(false); + } + + function cancelEdit() { + setEditing(false); + } + + return ( + { + if (editing) return; + onMouseDown?.(e); + }} + onMouseEnter={() => { + if (editing) return; + onMouseEnter?.(); + }} + > + {point.index} + {fmt.mv(point.volt_mv, 0)} + + {/* Offset — click to edit inline */} + { e.stopPropagation(); startEdit(); }}> + {editing ? ( + setInputValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); } + if (e.key === 'Escape') cancelEdit(); + }} + className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs" + style={{ fontFamily: 'monospace' }} + /> + ) : ( + + {hasPending && ✎} + {displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz + + )} + + + {/* Eff. Freq */} + + {fmt.mhz(displayEffMhz, 0)} + {isClamped && !hasPending && ( + ⇡ + )} + + + + {isCurrent && ◀ now} + {isSelected && !isCurrent && ●} + + + ); +} diff --git a/frontend/src/components/PointTable/PointTable.tsx b/frontend/src/components/PointTable/PointTable.tsx new file mode 100644 index 0000000..50b23d2 --- /dev/null +++ b/frontend/src/components/PointTable/PointTable.tsx @@ -0,0 +1,119 @@ +import { useState, useMemo, useEffect } from 'react'; +import { PointRow } from './PointRow'; +import type { VFPoint } from '../../types'; +import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers'; +import { useCurveStore } from '../../store/curveStore'; + +interface Props { + points: VFPoint[]; + currentVoltageMv: number | null; + readOnly?: boolean; +} + +export function PointTable({ points, currentVoltageMv, readOnly }: Props) { + const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore(); + const currentPoint = findCurrentPoint(points, currentVoltageMv); + const clampedPoints = useMemo(() => detectClampedPoints(points), [points]); + + const [dragStartIdx, setDragStartIdx] = useState(null); + + useEffect(() => { + function onUp() { setDragStartIdx(null); } + window.addEventListener('mouseup', onUp); + return () => window.removeEventListener('mouseup', onUp); + }, []); + + return ( +
+
+ Points + {!readOnly && pendingDeltas.size > 0 && ( + + {pendingDeltas.size} staged + + )} + {readOnly && ( + read-only + )} + {points.length} points + {!readOnly && selectedPoints.size === 1 && ( +
+ + +
+ )} +
+
+ + + + + + + + + + + {points.map((p) => ( + { + if (e.shiftKey) { + const currentSelected = Array.from(selectedPoints); + if (currentSelected.length > 0) { + const last = Math.max(...currentSelected); + const min = Math.min(last, p.index); + const max = Math.max(last, p.index); + const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); + selectRange(toSelect); + } else { + selectPoint(p.index, false); + } + } else if (e.ctrlKey || e.metaKey) { + selectPoint(p.index, true); + } else { + setDragStartIdx(p.index); + selectPoint(p.index, false); + } + }} + onMouseEnter={readOnly ? undefined : () => { + if (dragStartIdx !== null) { + const min = Math.min(dragStartIdx, p.index); + const max = Math.max(dragStartIdx, p.index); + const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); + selectRange(toSelect); + } + }} + /> + ))} + +
#VoltageOffsetEff. Freq +
+
+
+ ); +} diff --git a/frontend/src/components/Profiles/ProfilePanel.tsx b/frontend/src/components/Profiles/ProfilePanel.tsx new file mode 100644 index 0000000..fc3df63 --- /dev/null +++ b/frontend/src/components/Profiles/ProfilePanel.tsx @@ -0,0 +1,337 @@ +import { useState, useEffect, useRef } from 'react'; +import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react'; +import { api } from '../../api/client'; +import type { ProfileData } from '../../types'; +import { toast } from 'sonner'; +import { useCurveStore } from '../../store/curveStore'; + +interface ProfilePanelProps { + activeProfile: string | null; + onProfileApplied: (name: string | null) => void; +} + +export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) { + const { selectedGpuIndex, gpuInfo } = useCurveStore(); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [autoLoadProfile, setAutoLoadProfile] = useState(null); + + // Save form + const [isSaveOpen, setIsSaveOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const saveInputRef = useRef(null); + + // Inline delete confirmation + const [deletingName, setDeletingName] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Inline rename + const [renamingName, setRenamingName] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + + // Apply in-flight + const [applyingName, setApplyingName] = useState(null); + + async function fetchProfiles() { + try { + const data = await api.profiles(selectedGpuIndex); + setProfiles(data.profiles); + onProfileApplied(data.active); + setAutoLoadProfile(data.auto_load); + } catch { + toast.error('Failed to load profiles'); + } finally { + setLoading(false); + } + } + + async function handleSetAutoLoad(name: string | null) { + try { + await api.setAutoLoadProfile(name, selectedGpuIndex); + setAutoLoadProfile(name); + if (name) toast.success(`"${name}" will load on server start`); + else toast.success('Auto-load cleared'); + } catch (e: any) { + toast.error('Failed to update default profile: ' + (e.message || String(e))); + } + } + + useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]); + + useEffect(() => { + if (isSaveOpen) saveInputRef.current?.focus(); + else setNewName(''); + }, [isSaveOpen]); + + useEffect(() => { + if (renamingName) renameInputRef.current?.focus(); + }, [renamingName]); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + if (!newName.trim()) return; + try { + setIsSaving(true); + await api.saveProfile(newName.trim(), selectedGpuIndex); + toast.success(`Profile "${newName.trim()}" saved`); + setIsSaveOpen(false); + await fetchProfiles(); + } catch (e: any) { + toast.error('Failed to save: ' + (e.message || String(e))); + } finally { + setIsSaving(false); + } + } + + async function handleApply(name: string) { + try { + setApplyingName(name); + await api.applyProfile(name, selectedGpuIndex); + onProfileApplied(name); + toast.success(`"${name}" applied`); + } catch (e: any) { + toast.error(`Failed to apply "${name}": ` + (e.message || String(e))); + } finally { + setApplyingName(null); + } + } + + async function handleDeleteConfirm(name: string) { + try { + setIsDeleting(true); + await api.deleteProfile(name); + toast.success(`"${name}" deleted`); + if (activeProfile === name) onProfileApplied(null); + if (autoLoadProfile === name) setAutoLoadProfile(null); + setDeletingName(null); + await fetchProfiles(); + } catch (e: any) { + toast.error('Failed to delete: ' + (e.message || String(e))); + } finally { + setIsDeleting(false); + } + } + + async function handleRename(e: React.FormEvent, oldName: string) { + e.preventDefault(); + if (!renameValue.trim() || renameValue.trim() === oldName) { + setRenamingName(null); + return; + } + try { + setIsRenaming(true); + await api.renameProfile(oldName, renameValue.trim()); + toast.success(`Renamed to "${renameValue.trim()}"`); + if (activeProfile === oldName) onProfileApplied(renameValue.trim()); + if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim()); + setRenamingName(null); + await fetchProfiles(); + } catch (e: any) { + toast.error('Failed to rename: ' + (e.message || String(e))); + } finally { + setIsRenaming(false); + } + } + + if (loading && profiles.length === 0) { + return ( +
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +

Profiles

+
+ + {/* Save section */} +
+ {isSaveOpen ? ( +
+ setNewName(e.target.value)} + disabled={isSaving} + onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)} + className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" + /> + + +
+ ) : ( + + )} +
+ + {/* Profile list */} +
+ {profiles.length === 0 ? ( +

+ No profiles saved yet. +

+ ) : ( +
+ {profiles.map(p => { + const pts = Object.keys(p.curve_deltas).length; + const badges = [ + pts > 0 ? `${pts} pts` : null, + p.power_limit_w != null ? `${p.power_limit_w}W` : null, + p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null, + ].filter(Boolean).join(' · '); + + const isActive = activeProfile === p.name; + const isAutoLoad = autoLoadProfile === p.name; + const isApplying = applyingName === p.name; + const isConfirmingDelete = deletingName === p.name; + const isRenaming_ = renamingName === p.name; + + return ( +
+ {isConfirmingDelete ? ( +
+ Delete "{p.name}"? +
+ + +
+
+ ) : isRenaming_ ? ( +
handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2"> + setRenameValue(e.target.value)} + disabled={isRenaming} + onKeyDown={e => e.key === 'Escape' && setRenamingName(null)} + className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" + /> + + +
+ ) : ( +
+
+ {isActive + ? + : + } +
+
+

+ {p.name} +

+ {isAutoLoad && ( + + )} + {gpuInfo && p.gpu_name !== gpuInfo.name && ( + + {p.gpu_name} + + )} +
+ {badges &&

{badges}

} +
+
+ +
+ + + + +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/common/ConfirmDialog.tsx b/frontend/src/components/common/ConfirmDialog.tsx new file mode 100644 index 0000000..11389bb --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,50 @@ +interface Props { + message: string; + detail?: string; + confirmLabel?: string; + isDestructive?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + message, + detail, + confirmLabel = 'Confirm', + isDestructive = false, + onConfirm, + onCancel, +}: Props) { + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
+

{message}

+ {detail &&

{detail}

} + {!detail &&
} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useCurve.ts b/frontend/src/hooks/useCurve.ts new file mode 100644 index 0000000..4cde202 --- /dev/null +++ b/frontend/src/hooks/useCurve.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../api/client'; +import { createWsConnection } from '../api/websocket'; +import { useCurveStore } from '../store/curveStore'; +import type { CurveState } from '../types'; + +export function useCurve() { + const { curve, setCurve, selectedGpuIndex } = useCurveStore(); + const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const wsRef = useRef | null>(null); + + useEffect(() => { + // Initial fetch + api.curve(selectedGpuIndex).then(setCurve).catch(console.error); + + // Subscribe to /ws/curve for push updates after writes + wsRef.current = createWsConnection( + '/ws/curve', + (data) => setCurve(data), + setWsStatus, + selectedGpuIndex, + ); + + return () => wsRef.current?.close(); + }, [setCurve, selectedGpuIndex]); + + return { curve, wsStatus }; +} diff --git a/frontend/src/hooks/useGpu.ts b/frontend/src/hooks/useGpu.ts new file mode 100644 index 0000000..665d3fb --- /dev/null +++ b/frontend/src/hooks/useGpu.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; +import { api } from '../api/client'; +import { useCurveStore } from '../store/curveStore'; + +export function useGpu() { + const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore(); + + useEffect(() => { + api.gpus().then(setAvailableGpus).catch(console.error); + }, [setAvailableGpus]); + + useEffect(() => { + api.gpu(selectedGpuIndex).then(setGpuInfo).catch(console.error); + }, [setGpuInfo, selectedGpuIndex]); + + return gpuInfo; +} diff --git a/frontend/src/hooks/useMonitor.ts b/frontend/src/hooks/useMonitor.ts new file mode 100644 index 0000000..e8ada76 --- /dev/null +++ b/frontend/src/hooks/useMonitor.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef, useState } from 'react'; +import { createWsConnection } from '../api/websocket'; +import { useCurveStore } from '../store/curveStore'; +import type { MonitoringSample } from '../types'; + +export function useMonitor() { + const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore(); + const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const wsRef = useRef | null>(null); + + useEffect(() => { + wsRef.current = createWsConnection( + '/ws/monitor', + pushMonitor, + setWsStatus, + selectedGpuIndex, + ); + return () => wsRef.current?.close(); + }, [pushMonitor, selectedGpuIndex]); + + return { monitor, monitorHistory, wsStatus }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..1d29f68 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body, #root { + height: 100%; + margin: 0; + padding: 0; +} + +body { + background-color: #09090b; /* zinc-950 */ + color: #f4f4f5; /* zinc-100 */ + font-family: system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* Tailwind v4 dark mode via media (fallback) */ +@media (prefers-color-scheme: light) { + body { + background-color: #09090b; + color: #f4f4f5; + } +} + +/* scrollbar styling for dark theme */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: #18181b; +} +::-webkit-scrollbar-thumb { + background: #3f3f46; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #52525b; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/store/curveStore.ts b/frontend/src/store/curveStore.ts new file mode 100644 index 0000000..b20a6ab --- /dev/null +++ b/frontend/src/store/curveStore.ts @@ -0,0 +1,249 @@ +import { create } from 'zustand'; +import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types'; +import { api } from '../api/client'; +import { toast } from 'sonner'; + +const HISTORY_SIZE = 120; // ~60s at 2Hz + +interface CurveStore { + // Hardware state (from API) + availableGpus: GpuInfo[]; + selectedGpuIndex: number; + curve: CurveState | null; + gpuInfo: GpuInfo | null; + monitor: MonitoringSample | null; + monitorHistory: MonitoringSample[]; + + activeProfile: string | null; + + // Phase 4 — edit state + /** point index → pending delta in kHz (overrides curve.points[i].delta_khz for display) */ + pendingDeltas: Map; + selectedPoints: Set; + /** + * The anchor point for multi-point operations (e.g. flatten). + * Set to the last explicitly clicked point. Bulk selects (box, range, Ctrl+A) + * leave it unchanged; clearSelection resets it to null. + * If null or not in selectedPoints, operations fall back to the lowest selected index. + */ + anchorPoint: number | null; + + // Hardware state setters + setAvailableGpus: (gpus: GpuInfo[]) => void; + setSelectedGpuIndex: (index: number) => void; + setCurve: (c: CurveState) => void; + setGpuInfo: (g: GpuInfo) => void; + pushMonitor: (s: MonitoringSample) => void; + setActiveProfile: (name: string | null) => void; + + // Edit actions + stageEdit: (pointIndex: number, deltaKhz: number) => void; + stageMultiEdit: (edits: Map) => void; + stageRangeEdit: (points: VFPoint[], deltaKhz: number) => void; + discardEdits: () => void; + /** + * POST pending deltas to the backend, then call onSuccess (which re-fetches + * the curve) and clear the pending state. + */ + applyEdits: (onSuccess: () => void) => Promise; + /** POST /api/curve/reset, call onSuccess, clear pending state. */ + resetAllDeltas: (onSuccess: () => void) => Promise; + + // Selection actions + selectPoint: (index: number, multi?: boolean) => void; + selectRange: (indices: number[]) => void; + clearSelection: () => void; + /** + * Stage all selected points to the anchor point's current effective delta. + * Falls back to the lowest selected index if anchorPoint is null or deselected. + * No-op if fewer than 2 points are selected. + */ + flattenToAnchor: () => void; + + // Derived helper — effective MHz for a point including any pending delta + effectiveMhz: (point: VFPoint) => number; + // True if any pending delta would produce a negative effective frequency + hasNegativeFreqWarning: () => boolean; +} + +export const useCurveStore = create()((set, get) => ({ + availableGpus: [], + selectedGpuIndex: 0, + curve: null, + gpuInfo: null, + monitor: null, + monitorHistory: [], + activeProfile: null, + pendingDeltas: new Map(), + selectedPoints: new Set(), + anchorPoint: null, + + setAvailableGpus: (availableGpus) => set({ availableGpus }), + setSelectedGpuIndex: (selectedGpuIndex) => { + set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }); + }, + setCurve: (curve) => set({ curve }), + setGpuInfo: (gpuInfo) => set({ gpuInfo }), + setActiveProfile: (activeProfile) => set({ activeProfile }), + pushMonitor: (sample) => + set((s) => { + const history = [...s.monitorHistory, sample]; + if (history.length > HISTORY_SIZE) history.shift(); + return { monitor: sample, monitorHistory: history }; + }), + + stageEdit: (pointIndex, deltaKhz) => + set((s) => { + const next = new Map(s.pendingDeltas); + const point = s.curve?.points.find(p => p.index === pointIndex); + if (point && point.delta_khz === deltaKhz) { + next.delete(pointIndex); + } else { + next.set(pointIndex, deltaKhz); + } + return { pendingDeltas: next }; + }), + + stageMultiEdit: (edits) => + set((s) => { + const next = new Map(s.pendingDeltas); + edits.forEach((deltaKhz, index) => { + const point = s.curve?.points.find(p => p.index === index); + if (point && point.delta_khz === deltaKhz) { + next.delete(index); + } else { + next.set(index, deltaKhz); + } + }); + return { pendingDeltas: next }; + }), + + stageRangeEdit: (points, deltaKhz) => + set((s) => { + const next = new Map(s.pendingDeltas); + for (const p of points) { + next.set(p.index, deltaKhz); + } + return { pendingDeltas: next }; + }), + + discardEdits: () => + set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }), + + applyEdits: async (onSuccess) => { + const { pendingDeltas, selectedGpuIndex } = get(); + if (pendingDeltas.size === 0) return; + + // Convert Map to plain record for the API + const deltas: Record = {}; + pendingDeltas.forEach((v, k) => { deltas[k] = v; }); + + try { + const result = await api.writeDeltas(deltas, selectedGpuIndex); + set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); + if (result?.freq_warnings?.length) { + toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)'); + } else { + toast.success('Curve applied successfully'); + } + onSuccess(); + } catch (e: any) { + toast.error('Failed to apply curve: ' + (e.message || String(e))); + } + }, + + resetAllDeltas: async (onSuccess) => { + const { selectedGpuIndex } = get(); + try { + await api.resetCurve(selectedGpuIndex); + set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); + toast.success('Curve reset to hardware defaults'); + onSuccess(); + } catch (e: any) { + toast.error('Failed to reset curve: ' + (e.message || String(e))); + } + }, + + selectPoint: (index, multi = false) => + set((s) => { + const next = new Set(s.selectedPoints); + let anchor = s.anchorPoint; + if (multi) { + if (next.has(index)) { + next.delete(index); + if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null; + } else { + next.add(index); + anchor = index; // last explicitly added point is the new anchor + } + } else { + if (next.size === 1 && next.has(index)) { + next.clear(); + anchor = null; + } else { + next.clear(); + next.add(index); + anchor = index; + } + } + return { selectedPoints: next, anchorPoint: anchor }; + }), + + selectRange: (indices) => + // Bulk selects don't change the anchor — preserve it if still in the new selection. + set((s) => { + const next = new Set(indices); + const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null; + return { selectedPoints: next, anchorPoint: anchor }; + }), + + clearSelection: () => + set({ selectedPoints: new Set(), anchorPoint: null }), + + flattenToAnchor: () => { + const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get(); + if (selectedPoints.size < 2 || !curve) return; + + const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) + ? anchorPoint + : Math.min(...selectedPoints); + + const anchorPt = curve.points.find(p => p.index === anchor); + if (!anchorPt) return; + const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz; + const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta; + + const edits = new Map(); + for (const idx of selectedPoints) { + const pt = curve.points.find(p => p.index === idx); + if (!pt) continue; + edits.set(idx, anchorEffectiveKhz - pt.freq_khz); + } + stageMultiEdit(edits); + }, + + effectiveMhz: (point) => { + const { pendingDeltas } = get(); + if (pendingDeltas.has(point.index)) { + // freq_mhz is the current effective (VFP freq, already includes applied delta). + // Adjust by the change in delta: (pending - current_delta). + const pendingKhz = pendingDeltas.get(point.index)!; + const deltaChange = pendingKhz - point.delta_khz; + return point.freq_mhz + deltaChange / 1000; + } + return point.freq_mhz; + }, + + hasNegativeFreqWarning: () => { + const { pendingDeltas, curve } = get(); + if (!curve || pendingDeltas.size === 0) return false; + for (const [index, pendingKhz] of pendingDeltas) { + const p = curve.points.find((pt) => pt.index === index); + if (!p || p.freq_khz === 0) continue; + // freq_khz is current effective; true base = freq_khz - delta_khz + // new effective = true_base + pendingKhz = freq_khz + (pendingKhz - delta_khz) + if (p.freq_khz + pendingKhz - p.delta_khz < 0) return true; + } + return false; + }, +})); diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..f6188d5 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,75 @@ +export interface VFPoint { + index: number; + freq_khz: number; + freq_mhz: number; + volt_uv: number; + volt_mv: number; + delta_khz: number; + delta_mhz: number; + effective_freq_khz: number; + effective_freq_mhz: number; + domain: 'gpu' | 'memory'; +} + +export interface CurveState { + gpu_name: string; + timestamp: number; + points: VFPoint[]; +} + +export interface MonitoringSample { + timestamp: number; + voltage_uv: number | null; + voltage_mv: number | null; + clock_mhz: number | null; + temp_c: number | null; + power_w: number | null; + fan_pct: number | null; + pstate: number | null; + pstate_label: string | null; + mem_used_bytes: number | null; + mem_total_bytes: number | null; + mem_used_mib: number | null; + mem_total_mib: number | null; + gpu_util_pct: number | null; + mem_util_pct: number | null; + mem_clock_mhz: number | null; +} + +export interface GpuInfo { + name: string; + index: number; + driver_version: string | null; + vram_bytes: number | null; + vram_gib: number | null; +} + +export interface SnapshotInfo { + filepath: string; + timestamp: string; + gpu: string; + nonzero_offsets: number; + size: number; +} + +export interface LimitsState { + // Power + power_limit_w: number | null; + default_power_limit_w: number | null; + min_power_limit_w: number | null; + max_power_limit_w: number | null; + // Clock offsets — current values + gpc_offset_mhz: number | null; + mem_offset_mhz: number | null; + // Memory offset — hardware-reported range + min_mem_offset_mhz: number | null; + max_mem_offset_mhz: number | null; +} + +export interface ProfileData { + name: string; + gpu_name: string; + curve_deltas: Record; + mem_offset_mhz: number | null; + power_limit_w: number | null; +} diff --git a/frontend/src/utils/curveHelpers.ts b/frontend/src/utils/curveHelpers.ts new file mode 100644 index 0000000..9aa9aaa --- /dev/null +++ b/frontend/src/utils/curveHelpers.ts @@ -0,0 +1,77 @@ +import type { VFPoint } from '../types'; + +/** + * Approximate reference frequency (MHz) for a point: effective − delta. + * + * `p.freq_mhz` is the current effective frequency as reported by GetVFPCurve + * (already including any applied boost delta). Subtracting the delta gives an + * approximation of the hardware base. + * + * Note: NVIDIA enforces monotonicity across the V/F curve — a large delta on + * a lower-voltage point pushes up neighbouring points' effective frequencies + * even when those points have zero delta. So for monotonicity-affected points, + * `effective - delta` doesn't recover the true unmodified base. It is still + * useful as a faint reference line ("where this point would sit with no boost"). + */ +export function refBaseMhz(p: VFPoint): number { + return p.freq_mhz - p.delta_mhz; +} + +/** Find which VF point the GPU is currently near based on voltage reading */ +export function findCurrentPoint( + points: VFPoint[], + voltage_mv: number | null, +): VFPoint | null { + if (voltage_mv == null || points.length === 0) return null; + return points.reduce((best, p) => + Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv) ? p : best, + ); +} + +/** Voltage domain extent, with padding */ +export function voltExtent(points: VFPoint[], padMv = 20): [number, number] { + if (points.length === 0) return [600, 1100]; + const min = Math.min(...points.map((p) => p.volt_mv)); + const max = Math.max(...points.map((p) => p.volt_mv)); + return [min - padMv, max + padMv]; +} + +/** Frequency domain extent for the effective (boosted) curve, with padding */ +export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] { + if (points.length === 0) return [1000, 3000]; + const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]); + const min = Math.min(...allFreqs); + const max = Math.max(...points.map((p) => p.freq_mhz)); + return [min - padMhz, max + padMhz]; +} + +/** + * Detect points whose effective frequency is being held up by NVIDIA's + * monotonicity enforcement rather than their own offset. + * + * Walk points in voltage order, tracking the "ceiling" — the highest + * effective frequency seen so far and the offset that produced it. A point is + * clamped when: + * 1. Its effective freq is at or below the ceiling (hasn't moved past it) + * 2. Its own offset is lower than the offset that set the ceiling + * + * This catches cases like: point 103 has +950 MHz → effective 3907 MHz, + * point 104 has +315 MHz → effective also 3907 MHz (clamped). + */ +export function detectClampedPoints(points: VFPoint[]): Set { + const clamped = new Set(); + let ceiling = -Infinity; + let ceilingOffset = -Infinity; + + for (const p of points) { + if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) { + clamped.add(p.index); + } + if (p.freq_mhz > ceiling) { + ceiling = p.freq_mhz; + ceilingOffset = p.delta_khz; + } + } + + return clamped; +} diff --git a/frontend/src/utils/units.ts b/frontend/src/utils/units.ts new file mode 100644 index 0000000..df52bbd --- /dev/null +++ b/frontend/src/utils/units.ts @@ -0,0 +1,21 @@ +export const fmt = { + mhz: (v: number | null | undefined, decimals = 0) => + v == null ? '—' : `${v.toFixed(decimals)} MHz`, + mv: (v: number | null | undefined, decimals = 0) => + v == null ? '—' : `${v.toFixed(decimals)} mV`, + celsius: (v: number | null | undefined, decimals = 0) => + v == null ? '—' : `${v.toFixed(decimals)} °C`, + watts: (v: number | null | undefined, decimals = 0) => + v == null ? '—' : `${v.toFixed(decimals)} W`, + pct: (v: number | null | undefined, decimals = 0) => + v == null ? '—' : `${v.toFixed(decimals)}%`, + mib: (v: number | null | undefined) => + v == null ? '—' : `${v.toFixed(0)} MiB`, +}; + +export const conv = { + khzToMhz: (khz: number) => khz / 1000, + uvToMv: (uv: number) => uv / 1000, + mvToUv: (mv: number) => mv * 1000, + mhzToKhz: (mhz: number) => mhz * 1000, +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..cd6b806 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': 'http://localhost:8042', + '/ws': { target: 'ws://localhost:8042', ws: true }, + }, + allowedHosts: ["localhost", ".localhost", ".ts.net"] + }, +}) diff --git a/nvcurve/__init__.py b/nvcurve/__init__.py new file mode 100644 index 0000000..1ff8abc --- /dev/null +++ b/nvcurve/__init__.py @@ -0,0 +1,3 @@ +"""nvcurve — Linux GPU V/F curve editor for NVIDIA GPUs.""" + +__version__ = "0.5.0" diff --git a/nvcurve/__main__.py b/nvcurve/__main__.py new file mode 100644 index 0000000..bec7271 --- /dev/null +++ b/nvcurve/__main__.py @@ -0,0 +1,10 @@ +"""Allow running as: python -m nvcurve""" + +import sys + +if len(sys.argv) > 1 and sys.argv[1] == "daemon": + from .daemon import run + run() +else: + from .cli import main + main() diff --git a/nvcurve/cli.py b/nvcurve/cli.py new file mode 100644 index 0000000..9772fab --- /dev/null +++ b/nvcurve/cli.py @@ -0,0 +1,1838 @@ +"""nvcurve CLI + +Normal use: + nvcurve Launch web UI (escalates to root if needed) + nvcurve read [--full|--json] Read V/F curve (escalates to root) + nvcurve write [--point N|--range A-B|--global|--reset] --delta D [--dry-run] + nvcurve verify --point N --delta D Write-verify cycle (requires root) + nvcurve snapshot [save|restore|list] Manage snapshots + nvcurve gpus List detected NVIDIA GPUs + nvcurve profile [save|apply|list|default] Manage profiles + +Web server (on-demand, for the GUI): + nvcurve serve start [--detach] Start web server (escalates to root) + nvcurve serve stop Stop running web server + nvcurve serve status Check web server status + +Daemon (systemd service for auto-load profiles): + nvcurve daemon Run the daemon (requires root) + nvcurve autoload Apply auto-load profiles from config (requires root) + nvcurve service install [--serve] Register systemd daemon (escalates to root) + nvcurve service configure Update config + restart daemon (escalates to root) + nvcurve service uninstall Remove systemd service (escalates to root) + nvcurve service start/stop/restart/status Manage systemd service + +First-time / diagnostic commands (bypass server, escalate to root): + nvcurve setup Hardware compatibility check (diag + verify + restore) + nvcurve read --diag Probe all NvAPI functions + nvcurve read --raw Raw hex dumps of hardware buffers + nvcurve inspect [--point N|--range A-B] Raw ClockBoostTable field detail +""" + +import argparse +import json +import struct +import sys +import time +import os + +from .config import Config, default_config +from .client import NvCurveClient, ServerNotRunning, ApiError +from .nvapi.constants import ( + VFP_SIZE, VFP_BASE, VFP_STRIDE, + CT_SIZE, CT_BASE, CT_STRIDE, + CT_POINTS, +) + + +# ── Utilities ───────────────────────────────────────────────────────────────── + +def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str: + lines = [] + end = min(start + length, len(data)) + for off in range(start, end, cols): + chunk = data[off:off + cols] + hx = " ".join(f"{b:02x}" for b in chunk) + asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}") + return "\n".join(lines) + + +def parse_range(s: str): + """Parse 'A-B' into (A, B) tuple.""" + parts = s.split("-") + if len(parts) != 2: + raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'") + try: + a, b = int(parts[0]), int(parts[1]) + except ValueError: + raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") + if a > b: + raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}") + if a < 0 or b >= CT_POINTS: + raise argparse.ArgumentTypeError(f"Range {a}-{b} outside 0–{CT_POINTS - 1}") + return (a, b) + + +# ── Output formatters ───────────────────────────────────────────────────────── + +def print_curve(points, offsets, voltage, domains=None, full=False): + """Print formatted V/F curve table.""" + if voltage: + print(f"Current voltage: {voltage / 1000:.1f} mV") + + if domains: + gpu_count = sum(1 for d in domains if d == "gpu") + mem_count = sum(1 for d in domains if d == "memory") + parts = [f"{gpu_count} GPU core points"] + if mem_count: + parts.append(f"{mem_count} memory points") + parts.append(f"{gpu_count + mem_count} total") + print(f"Curve: {', '.join(parts)}") + print() + + current_idx = None + if voltage: + for i, (f, v) in enumerate(points): + if v > 0 and abs(v - voltage) < 10000: + current_idx = i + break + + if full: + show = list(range(len(points))) + else: + show = [] + prev_freq = -1 + for i, (f, v) in enumerate(points): + if f == 0 and v == 0: + continue + if domains and i < len(domains) and domains[i] == "memory": + show.append(i) + elif f != prev_freq or i == len(points) - 1: + show.append(i) + prev_freq = f + + if domains: + print(f"{'#':>3s} {'Freq':>8s} {'Voltage':>8s} {'Offset':>8s} {'Domain'}") + print("-" * 56) + else: + print(f"{'#':>3s} {'Freq':>8s} {'Voltage':>8s} {'Offset':>8s}") + print("-" * 42) + + for i in show: + f, v = points[i] + if f == 0 and v == 0: + continue + freq_s = f"{f / 1000:.0f} MHz" + volt_s = f"{v / 1000:.0f} mV" + offset_s = "" + if offsets and offsets[i] != 0: + offset_s = f"{offsets[i] / 1000:+.0f} MHz" + domain = domains[i] if domains and i < len(domains) else "" + marker = "" + if current_idx is not None and i == current_idx: + marker = " <-- current" + elif f < 1_000_000 and v > 0 and not domain: + marker = " (low-power)" + if domains: + print(f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s} {domain}{marker}") + else: + print(f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s}{marker}") + + print() + if domains: + gpu_idxs = [i for i, d in enumerate(domains) if d == "gpu"] + mem_idxs = [i for i, d in enumerate(domains) if d == "memory"] + + gpu_active = [(points[i][0], points[i][1]) for i in gpu_idxs + if i < len(points) and points[i][0] > 0] + if gpu_active: + freqs = [f for f, v in gpu_active] + volts = [v for f, v in gpu_active] + print(f"GPU core: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " + f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " + f"({len(gpu_active)} points)") + + mem_active = [(points[i][0], points[i][1]) for i in mem_idxs + if i < len(points) and points[i][0] > 0] + if mem_active: + freqs = [f for f, v in mem_active] + volts = [v for f, v in mem_active] + print(f"Memory: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " + f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " + f"({len(mem_active)} points)") + + if offsets: + gpu_offsets = [offsets[i] for i in gpu_idxs + if i < len(offsets) and offsets[i] != 0] + if gpu_offsets: + vals = set(gpu_offsets) + if len(vals) == 1: + print(f"GPU offset: {next(iter(vals))/1000:+.0f} MHz " + f"(uniform across {len(gpu_offsets)} points)") + else: + print(f"GPU offsets: {len(gpu_offsets)} points active " + f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)") + else: + active = [(f, v) for f, v in points if f > 0 and v > 0] + if active: + freqs = [f for f, v in active] + volts = [v for f, v in active] + print(f"Frequency range: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz") + print(f"Voltage range: {min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV") + print(f"V/F points: {len(active)}") + if offsets: + nonzero = sum(1 for o in offsets if o != 0) + if nonzero > 0: + vals = set(o for o in offsets if o != 0) + if len(vals) == 1: + print(f"Global offset: {next(iter(vals))/1000:+.0f} MHz " + f"(applied to {nonzero} points)") + else: + print(f"Per-point offsets active on {nonzero} points " + f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)") + + +def output_json(gpu_name, points, offsets, voltage, domains=None): + """Output JSON format.""" + gpu_points = [i for i, d in enumerate(domains) if d == "gpu"] if domains else [] + mem_points = [i for i, d in enumerate(domains) if d == "memory"] if domains else [] + data = { + "gpu": gpu_name, + "current_voltage_uV": voltage, + "layout": { + "vfp_curve": {"size": VFP_SIZE, "base": VFP_BASE, + "stride": VFP_STRIDE, "max_entries": len(points)}, + "clock_table": {"size": CT_SIZE, "base": CT_BASE, + "stride": CT_STRIDE, "delta_offset": 0x14, + "max_entries": CT_POINTS}, + }, + "curve_info": { + "gpu_points": gpu_points, + "mem_points": mem_points, + "total_points": len(points), + }, + "vf_curve": [], + } + if points: + for i, (f, v) in enumerate(points): + if f > 0 or v > 0: + entry = {"index": i, "freq_kHz": f, "volt_uV": v} + if offsets: + entry["freq_offset_kHz"] = offsets[i] + if domains and i < len(domains): + entry["domain"] = domains[i] + data["vf_curve"].append(entry) + print(json.dumps(data, indent=2)) + + +# ── Diagnostics (direct HAL, root required) ─────────────────────────────────── + +def run_diagnostics(gpu, gpu_name, gpu_index: int = 0): + """Probe all known NvAPI functions and report results.""" + from .hal.vfcurve import get_boost_mask + from .hal.monitoring import init_nvml, get_driver_version, get_vram_total + from .hal.limits import get_power_limit, get_clock_offsets, get_mem_offset_range + from .nvapi.bootstrap import nvcall, query_interface + from .nvapi.constants import FUNC, MASK_SIZE, VOLT_SIZE, RANGES_SIZE, PERF_SIZE, VBOOST_SIZE + + init_nvml() # best-effort; diagnostics degrade gracefully without it + + # ── System info ─────────────────────────────────────────────────────────── + print("=== System ===") + print() + driver = get_driver_version() + vram = get_vram_total(gpu_index) + print(f" GPU: {gpu_name}") + print(f" Driver: {driver or '(unavailable — NVML not initialised)'}") + if vram is not None: + print(f" VRAM: {vram / (1024 ** 3):.1f} GiB ({vram:,} bytes)") + else: + print(" VRAM: (unavailable)") + print() + + # ── Function probe ──────────────────────────────────────────────────────── + print("=== Function probe ===") + print() + + probes = [ + ("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1, True), + ("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1, True), + ("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1, True), + ("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1, False), + ("GetClockBoostRanges", FUNC["GetClockBoostRanges"], RANGES_SIZE, 1, False), + ("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2, False), + ("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1, False), + ("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True), + ] + + for name, fid, size, ver, needs_mask in probes: + ptr = query_interface(fid) + resolved = "resolved" if ptr else "NOT FOUND" + print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}") + + # ── Read function tests ─────────────────────────────────────────────────── + mask_bytes = None + print() + print("=== Read function tests ===") + + mask_bytes, mask_err = get_boost_mask(gpu) + if not mask_bytes: + print(f" WARNING: Failed to get boost mask: {mask_err}") + + for name, fid, size, ver, needs_mask in probes: + if name.startswith("Set"): + continue + + def fill(buf, _nm=needs_mask, _fid=fid, _mask=mask_bytes): + if _nm and _mask: + for i in range(32): + buf[4 + i] = _mask[i] + + d, err = nvcall(fid, gpu, size, ver=ver, pre_fill=fill) + status = f"OK ({len(d)} bytes)" if d else f"FAILED: {err}" + print(f" {name:30s} {status}") + if d: + vw = struct.unpack_from(" None: + """Open URL in browser, switching back to the original user if running under sudo.""" + import subprocess + sudo_user = os.environ.get("SUDO_USER") + if sudo_user and os.geteuid() == 0: + try: + subprocess.Popen( + ["runuser", "-u", sudo_user, "--", "xdg-open", url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except Exception: + pass + import webbrowser + webbrowser.open(url) + + + +def require_root(): + """Ensure the process is running as root, re-invoking via sudo if necessary.""" + if os.geteuid() != 0: + # Forward display/session vars so the server can open the browser as the + # original user (Wayland sockets are user-owned; Firefox refuses to run as root). + passthrough = [ + f"{k}={v}" + for k in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", "XAUTHORITY") + if (v := os.environ.get(k)) + ] + try: + # PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages. + os.execvp("sudo", [ + "sudo", "env", "PYTHONDONTWRITEBYTECODE=1", *passthrough, + sys.executable, "-m", "nvcurve", *sys.argv[1:] + ]) + except Exception as e: + print(f"nvcurve: sudo failed: {e}", file=sys.stderr) + sys.exit(1) + + +_SERVER_INFO_FILE = "/run/nvcurve.json" # runtime: written by server, deleted on exit +_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json" # persistent: written by service install +_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock" + +_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +def _daemon_send(cmd: dict) -> dict | None: + """Send a JSON command to the daemon and return its response. + + Returns None if the daemon socket is not available (daemon not running). + """ + import socket as _socket + try: + with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as sock: + sock.settimeout(5.0) + sock.connect(_DAEMON_SOCKET_PATH) + sock.sendall(json.dumps(cmd).encode() + b"\n") + buf = b"" + while not buf.endswith(b"\n"): + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + return json.loads(buf) + except (FileNotFoundError, ConnectionRefusedError, OSError): + return None + + +def _safe_host(host: str, cfg: Config) -> str: + """Return host if it is a loopback address, otherwise fall back to cfg.host. + + 0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local + server address, just not usable as a client connection target. + """ + if host in ("0.0.0.0", "::"): + return "127.0.0.1" + if host not in _ALLOWED_HOSTS: + print(f"nvcurve: ignoring untrusted host '{host}' in server info; " + f"using {cfg.host}", file=sys.stderr) + return cfg.host + return host + + +def _log_file() -> str: + return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" + + +def _read_server_info() -> dict | None: + """Read the server's runtime info (host, port, pid) from its info file. + + The file is written by the server process on startup and deleted on exit. + Returns None if the file is absent, stale, or unreadable. + """ + try: + with open(_SERVER_INFO_FILE) as f: + info = json.load(f) + # Verify the process is still alive + os.kill(info["pid"], 0) + return info + except (FileNotFoundError, KeyError, ProcessLookupError, OSError, json.JSONDecodeError): + return None + + +def _discover_server_url(cfg: Config) -> str: + """Return the server's base URL using a three-level priority chain: + + 1. /run/nvcurve.json — runtime info written by the running server process + 2. /etc/nvcurve/config.json — persistent config written by `service install` + 3. Config defaults — 127.0.0.1:8042 + """ + # 1. Runtime info (most accurate — reflects the actual running port) + info = _read_server_info() + if info: + host = _safe_host(info["host"], cfg) + return f"http://{host}:{info['port']}" + + # 2. Persistent config (survives reboots; written by `service install`) + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + data = json.load(f) + host = _safe_host(data.get("host", cfg.host), cfg) + port = data.get("port", cfg.port) + return f"http://{host}:{port}" + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + + # 3. Hardcoded defaults + return f"http://{cfg.host}:{cfg.port}" + + +# ── Subcommand handlers ─────────────────────────────────────────────────────── + +def _show_curve(gpu_name, points, offsets, voltage, args, domains=None) -> None: + """Format and print curve data — shared by HTTP and direct-HAL paths.""" + if args.json: + output_json(gpu_name, points, offsets, voltage, domains=domains) + return + print(f"GPU: {gpu_name}") + print_curve(points, offsets, voltage, domains=domains, full=args.full) + + +def cmd_read(args): + # ── Direct HAL paths (need root) ────────────────────────────────────────── + if args.diag: + require_root() + from .hal.gpu import get_gpu + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + run_diagnostics(gpu, gpu_name, gpu_index=getattr(args, "gpu_index", 0)) + return + + if args.raw: + require_root() + from .hal.gpu import get_gpu + from .hal.vfcurve import read_clock_table_raw, get_boost_mask + from .hal.monitoring import read_voltage + from .nvapi.bootstrap import nvcall + from .nvapi.constants import FUNC + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + + print(f"GPU: {gpu_name}") + + mask_bytes, _ = get_boost_mask(gpu) + def fill_vfp(buf): + if mask_bytes: + for i in range(32): + buf[4 + i] = mask_bytes[i] + + vfp_raw, _ = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill_vfp) + ct_raw, _ = read_clock_table_raw(gpu) + + if vfp_raw: + print() + print("=== VFP Curve (0x21537AD4) — header + first entries ===") + print(hexdump(vfp_raw, 0x00, 0x48)) + print(" --- data at 0x48, stride 0x1C ---") + print(hexdump(vfp_raw, 0x48, VFP_STRIDE * 5)) + + if ct_raw: + print() + print("=== ClockBoostTable (0x23F1B133) — header + first entries ===") + print(hexdump(ct_raw, 0x00, 0x44)) + print(" --- data at 0x44, stride 0x24, freqDelta at +0x14 ---") + print(hexdump(ct_raw, 0x44, CT_STRIDE * 5)) + print() + + from .hal.vfcurve import read_curve + curve_state, _ = read_curve(gpu, gpu_name) + voltage, _ = read_voltage(gpu) + if curve_state: + points = [(p.freq_khz, p.volt_uv) for p in curve_state.points] + offsets = [p.delta_khz for p in curve_state.points] + domains = [p.domain for p in curve_state.points] + _show_curve(gpu_name, points, offsets, voltage, args, domains=domains) + return + + # ── Normal path — direct HAL (requires root) ────────────────────────────── + require_root() + from .hal.gpu import get_gpu + from .hal.vfcurve import read_curve + from .hal.monitoring import read_voltage as _read_voltage + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + curve_state, curve_err = read_curve(gpu, gpu_name) + if not curve_state: + print(f"Failed to read V/F curve: {curve_err}") + return + voltage, _ = _read_voltage(gpu) + points = [(p.freq_khz, p.volt_uv) for p in curve_state.points] + offsets = [p.delta_khz for p in curve_state.points] + domains = [p.domain for p in curve_state.points] + _show_curve(gpu_name, points, offsets, voltage, args, domains=domains) + + +def cmd_inspect(args): + """Show detailed raw ClockBoostTable fields. Requires root (direct HAL).""" + require_root() + from .hal.gpu import get_gpu + from .hal.vfcurve import read_clock_table_raw, read_clock_entry_full, read_curve + + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + raw, err = read_clock_table_raw(gpu) + if not raw: + print(f"Failed to read ClockBoostTable: {err}") + return + + curve_state, _ = read_curve(gpu, gpu_name) + points_data = {} + gpu_indices = set() + mem_indices = set() + if curve_state: + for p in curve_state.points: + points_data[p.index] = (p.freq_khz, p.volt_uv) + if p.domain == "memory": + mem_indices.add(p.index) + else: + gpu_indices.add(p.index) + + if args.point is not None: + indices = [args.point] + elif args.range: + indices = list(range(args.range[0], args.range[1] + 1)) + else: + defaults = [0, 1, 50, 51, 80, 126] + if mem_indices: + mp = min(mem_indices) + defaults.extend([mp - 1, mp, mp + 1, max(mem_indices)]) + else: + defaults.append(127) + indices = sorted(set(defaults)) + + print(f"GPU: {gpu_name}") + if curve_state: + parts = [f"{len(gpu_indices)} GPU core points"] + if mem_indices: + parts.append(f"{len(mem_indices)} memory points") + parts.append(f"{len(gpu_indices) + len(mem_indices)} total") + print(f"Curve: {', '.join(parts)}") + print(f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, " + f"9 fields × 4 bytes)") + print() + + for p in indices: + if p < 0 or p >= CT_POINTS: + continue + entry = read_clock_entry_full(raw, p) + off = CT_BASE + p * CT_STRIDE + + domain_label = "" + if p in mem_indices: + domain_label = " [MEMORY]" + elif p in gpu_indices: + domain_label = " [GPU]" + + freq_str = "" + if p in points_data: + f, v = points_data[p] + freq_str = f" (VFP: {f/1000:.0f} MHz @ {v/1000:.0f} mV)" + + print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain_label}{freq_str}") + for key, val in entry.items(): + if key == "freqDelta_kHz": + continue + if "0x14" in key: + print(f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})" + f" = {val/1000:+.0f} MHz ← freqDelta") + else: + print(f" {key}: {val:12d} (0x{val:08X})") + print() + + +def cmd_write(args): + delta_khz = int(args.delta * 1000) + max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None + point_deltas = {} + + if args.reset: + if args.dry_run: + print("DRY RUN — would reset all offsets to 0.") + return + require_root() + from .hal.gpu import get_gpu + from .hal.vfcurve import reset_offsets + gpu, _ = get_gpu(index=getattr(args, "gpu_index", 0)) + reset_offsets(gpu) + print("Reset: all offsets set to 0.") + return + + elif args.point is not None: + point_deltas[args.point] = delta_khz + print(f"Target: point {args.point}, delta {args.delta:+.0f} MHz " + f"({delta_khz:+d} kHz)") + + elif args.range: + start, end = args.range + for i in range(start, end + 1): + point_deltas[i] = delta_khz + print(f"Target: points {start}–{end} ({len(point_deltas)} points), " + f"delta {args.delta:+.0f} MHz") + + elif args.glob: + print(f"Target: all active points (global), " + f"delta {args.delta:+.0f} MHz") + + else: + print("Error: specify --point N, --range A-B, --global, or --reset") + return + + if args.dry_run: + if args.glob: + print() + print("DRY RUN — would send:") + print(f" Target: Global active points") + print(f" Delta: {delta_khz:+d} kHz ({args.delta:+.0f} MHz)") + else: + keys = sorted(point_deltas.keys()) + preview = keys[:5] + tail = f"...and {len(keys) - 5} more" if len(keys) > 5 else "" + print() + print("DRY RUN — would send:") + print(f" Points: {preview}{(' ' + tail) if tail else ''}") + print(f" Delta: {delta_khz:+d} kHz ({args.delta:+.0f} MHz)") + if max_delta_khz is not None: + print(f" Max delta override: {args.max_delta:+.0f} MHz") + return + + require_root() + from .hal.gpu import get_gpu + from .hal.vfcurve import write_offsets, read_curve + from .safety import validate_write, check_negative_freq_warnings + + gpu_index = getattr(args, "gpu_index", 0) + gpu, gpu_name = get_gpu(index=gpu_index) + + if args.glob: + # Build per-point deltas for all active GPU-domain points (mirrors server behaviour) + curve_state, curve_err = read_curve(gpu, gpu_name) + if not curve_state: + print(f"Failed to read curve: {curve_err}", file=sys.stderr) + sys.exit(1) + point_deltas = {p.index: delta_khz for p in curve_state.points if p.domain == "gpu"} + + effective_max = max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz + errors = validate_write(point_deltas, effective_max) + if errors: + for e in errors: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if default_config.auto_snapshot: + from .hal.snapshot import save as snapshot_save + snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + + ret, desc = write_offsets(gpu, point_deltas) + if ret != 0: + print(f"Write failed ({ret}): {desc}", file=sys.stderr) + sys.exit(1) + + print(f"Write OK — {len(point_deltas)} point(s) updated.") + + try: + if not args.glob: + curve_state, _ = read_curve(gpu, gpu_name) + if curve_state: + vfp_freqs = [p.freq_khz for p in curve_state.points] + for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []): + print(f"WARNING: {w}") + except Exception: + pass + + +def cmd_verify(args): + """Write-verify-read cycle — runs directly against hardware (requires root).""" + require_root() + + from .hal.gpu import get_gpu + from .hal.vfcurve import read_clock_offsets, write_offsets + from .hal.snapshot import save as snapshot_save + + delta_khz = int(args.delta * 1000) + + if args.point is not None: + points = [args.point] + elif args.range: + points = list(range(args.range[0], args.range[1] + 1)) + else: + print("Error: --point or --range required for verify mode") + return + + point_deltas = {p: delta_khz for p in points} + + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + + print("=== Write-Verify Cycle ===") + print(f"GPU: {gpu_name}") + print(f"Points: {points[0]}{'–' + str(points[-1]) if len(points) > 1 else ''}") + print(f"Delta: {args.delta:+.0f} MHz ({delta_khz:+d} kHz)") + print() + + # Step 1: read before state + before_offsets, err = read_clock_offsets(gpu) + if before_offsets is None: + print(f"Failed to read current state: {err}") + return + + # Step 2: snapshot before write + filepath = snapshot_save(gpu, gpu_name, default_config.snapshot_dir) + if filepath: + print(f"Snapshot saved: {filepath}") + + # Step 3: write + print("Writing and verifying...") + ret, desc = write_offsets(gpu, point_deltas) + if ret != 0: + print(f"Write failed ({ret}): {desc}") + return + + time.sleep(0.2) + + # Step 4: read after state + after_offsets, err = read_clock_offsets(gpu) + if after_offsets is None: + print(f"Verification read failed: {err}") + return + + print() + print("Verification results:") + all_matched = True + for p in sorted(point_deltas): + expected = point_deltas[p] + actual = after_offsets[p] if p < len(after_offsets) else 0 + match = actual == expected + if not match: + all_matched = False + match_s = "OK" if match else "MISMATCH" + print(f" Point {p:3d}: expected {expected/1000:+8.0f} MHz, " + f"got {actual/1000:+8.0f} MHz [{match_s}]") + + collateral = [ + {"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]} + for i in range(min(len(before_offsets), len(after_offsets))) + if i not in point_deltas and before_offsets[i] != after_offsets[i] + ] + print() + if collateral: + print("Unintended side effects detected:") + for c in collateral: + print(f" WARNING: Point {c['point']} changed: " + f"{c['before_khz']/1000:+.0f} → {c['after_khz']/1000:+.0f} MHz") + else: + print("No unintended side effects detected.") + + print() + print("=" * 50) + if all_matched and not collateral: + print("RESULT: Write verified successfully.") + elif not all_matched: + print("RESULT: Write verification FAILED — offsets don't match.") + else: + print("RESULT: Write applied but with unexpected side effects.") + + print() + print("To undo this change, run:") + print(" nvcurve snapshot restore") + + +def cmd_snapshot(args): + if args.action == "save": + require_root() + from .hal.gpu import get_gpu + from .hal.snapshot import save as _snapshot_save + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + path = _snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + if path is None: + print("Failed to save snapshot.", file=sys.stderr) + sys.exit(1) + print(f"Snapshot saved: {path}") + + elif args.action == "restore": + require_root() + from .hal.gpu import get_gpu + from .hal.snapshot import restore as _snapshot_restore + gpu, _ = get_gpu(index=getattr(args, "gpu_index", 0)) + ok = _snapshot_restore(gpu, default_config.snapshot_dir, args.file) + if not ok: + print("Restore failed — no snapshot found.", file=sys.stderr) + sys.exit(1) + print("Snapshot restored.") + + elif args.action == "list": + from .hal.snapshot import list_snapshots as _list_snapshots + snapshots = [ + {"filepath": s.filepath, "timestamp": s.timestamp, + "gpu": s.gpu, "nonzero_offsets": s.nonzero_offsets} + for s in _list_snapshots(default_config.snapshot_dir) + ] + if not snapshots: + print("No snapshots found.") + return + print("Snapshots:") + for s in snapshots: + print(f" {s['timestamp']} {s['gpu']} non-zero: {s['nonzero_offsets']}") + print(f" {s['filepath']}") + + +def _profile_config_read() -> dict: + """Read /etc/nvcurve/config.json, returning {} if absent or unreadable.""" + import json as _json + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + return _json.load(f) + except Exception: + return {} + + +def _profile_config_write(key: str, value) -> None: + """Write a single key into /etc/nvcurve/config.json (creates or updates).""" + import json as _json + import os as _os + _os.makedirs(_os.path.dirname(_PERSISTENT_CONFIG_FILE), exist_ok=True) + data = _profile_config_read() + if value is None: + data.pop(key, None) + else: + data[key] = value + with open(_PERSISTENT_CONFIG_FILE, "w") as f: + _json.dump(data, f, indent=2) + + +def _gpu_stable_key_offline(gpu_index: int) -> str | None: + """Resolve a stable GPU key offline (without the server) by calling discover_gpus(). + + Returns None if the GPU index does not exist (discovery succeeded but index not found). + Falls back to 'idx:{n}' if discovery itself fails (can't tell whether GPU exists). + """ + try: + from .hal.gpu import discover_gpus + infos = discover_gpus() + for info in infos: + if info.index == gpu_index: + if info.uuid: + return info.uuid + if info.pci_bus_id is not None: + return f"pci:{info.pci_bus_id:04x}" + return f"idx:{gpu_index}" + # Discovery succeeded but this index wasn't among the found GPUs. + return None + except Exception: + # Discovery failed entirely — fall back, can't validate existence. + return f"idx:{gpu_index}" + + +def _profile_config_set_default(gpu_index: int, name: str | None) -> None: + """Set or clear the default profile for a specific GPU in config.json.""" + import json as _json + import os as _os + _os.makedirs(_os.path.dirname(_PERSISTENT_CONFIG_FILE), exist_ok=True) + data = _profile_config_read() + # Migrate old single-key format on write + if "auto_load_profile" in data: + data.setdefault("auto_load_profiles", {})["idx:0"] = data.pop("auto_load_profile") + gpu_key = _gpu_stable_key_offline(gpu_index) + if gpu_key is None: + raise ValueError(f"GPU {gpu_index} not found") + profiles = data.setdefault("auto_load_profiles", {}) + if name is None: + profiles.pop(gpu_key, None) + else: + profiles[gpu_key] = name + if not profiles: + data.pop("auto_load_profiles", None) + with open(_PERSISTENT_CONFIG_FILE, "w") as f: + _json.dump(data, f, indent=2) + + +def cmd_profile(args): + if args.action == "list": + import glob as _glob, json as _json, os as _os + gpu_index = getattr(args, "gpu_index", 0) + profile_dir = default_config.profile_dir + cfg_data = _profile_config_read() + raw_defaults = cfg_data.get("auto_load_profiles", {}) + if not raw_defaults and "auto_load_profile" in cfg_data: + raw_defaults = {"idx:0": cfg_data["auto_load_profile"]} + gpu_key = _gpu_stable_key_offline(gpu_index) + auto_load = raw_defaults.get(gpu_key) if gpu_key is not None else None + raw = sorted(_glob.glob(_os.path.join(profile_dir, "*.json"))) + profiles = [] + for path in raw: + try: + with open(path) as f: + p = _json.load(f) + name = _os.path.splitext(_os.path.basename(path))[0] + profiles.append({"name": name, "curve_deltas": p.get("curve_deltas", {})}) + except Exception: + pass + if not profiles: + print("No profiles found.") + return + print("Profiles:") + for p in profiles: + markers = [] + if p["name"] == auto_load: markers.append("default") + marker_str = f" [{', '.join(markers)}]" if markers else "" + pts = len(p["curve_deltas"]) + print(f" - {p['name']} ({pts} pts){marker_str}") + + elif args.action == "default": + clearing = getattr(args, "clear", False) + gpu_index = getattr(args, "gpu_index", 0) + if not clearing and not args.name: + print("Error: profile name required (or use --clear)") + return + require_root() + try: + _profile_config_set_default(gpu_index, None if clearing else args.name) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return + if clearing: + print(f"Auto-load profile cleared for GPU {gpu_index}.") + else: + print(f"Auto-load profile set to '{args.name}' for GPU {gpu_index}.") + + elif args.action == "save": + if not args.name: + print("Error: profile name required for save") + return + require_root() + import os as _os + from .hal.gpu import get_gpu + from .hal.vfcurve import read_curve + from .hal.limits import get_clock_offsets, get_power_limit + from .profiles.native import ProfileData, save_profile + + gpu_index = getattr(args, "gpu_index", 0) + gpu, gpu_name = get_gpu(index=gpu_index) + + curve_state, curve_err = read_curve(gpu, gpu_name) + if not curve_state: + print(f"Failed to read curve: {curve_err}", file=sys.stderr) + sys.exit(1) + + curve_deltas = {str(p.index): p.delta_khz for p in curve_state.points if p.delta_khz != 0} + + try: + power_info = get_power_limit(gpu_index) + offsets = get_clock_offsets(gpu_index) + power_limit_w = power_info.get("power_limit_w") + mem_offset_mhz = offsets.get("mem_offset_mhz") + except Exception: + power_limit_w = None + mem_offset_mhz = None + + data = ProfileData( + name=args.name, + gpu_name=gpu_name, + curve_deltas=curve_deltas, + mem_offset_mhz=mem_offset_mhz, + power_limit_w=power_limit_w, + ) + filepath = save_profile(default_config.profile_dir, data) + print(f"Saved profile '{args.name}' to {filepath}") + + elif args.action == "apply": + if not args.name: + print("Error: profile name required for apply") + return + require_root() + import os as _os + from .hal.gpu import get_gpu + from .hal.limits import set_clock_offsets, set_power_limit + from .hal.vfcurve import write_offsets, reset_offsets + from .hal.snapshot import save as snapshot_save + from .profiles.native import load_profile + from .safety import validate_write + + gpu_index = getattr(args, "gpu_index", 0) + gpu, gpu_name = get_gpu(index=gpu_index) + + safe_name = "".join(c for c in args.name if c.isalnum() or c in " _-()").strip() + filepath = _os.path.join(default_config.profile_dir, f"{safe_name}.json") + try: + profile = load_profile(filepath) + except FileNotFoundError: + print(f"Profile '{args.name}' not found.", file=sys.stderr) + sys.exit(1) + + errs = [] + if profile.mem_offset_mhz is not None: + ok, msg = set_clock_offsets(None, profile.mem_offset_mhz, gpu_index) + if not ok: + errs.append(f"Mem offset: {msg}") + + if profile.power_limit_w is not None: + ok, msg = set_power_limit(profile.power_limit_w, gpu_index) + if not ok: + errs.append(f"Power limit: {msg}") + + if profile.curve_deltas: + deltas = {int(k): v for k, v in profile.curve_deltas.items()} + errors = validate_write(deltas, default_config.max_delta_khz) + if errors: + errs.append("Curve: " + "; ".join(errors)) + else: + if default_config.auto_snapshot: + snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + ret, desc = write_offsets(gpu, deltas) + if ret != 0: + errs.append(f"Curve write failed ({ret}): {desc}") + else: + reset_offsets(gpu) + + for e in errs: + print(f" warning: {e}", file=sys.stderr) + print(f"Applied profile '{args.name}'.") + + +def cmd_gpus(args): + """List all detected NVIDIA GPUs with index, name, UUID, and PCI bus ID.""" + from .hal.gpu import discover_gpus + gpus = [ + {"index": i.index, "name": i.name, "uuid": i.uuid, "pci_bus_id": i.pci_bus_id} + for i in discover_gpus() + ] + if not gpus: + print("No NVIDIA GPUs detected.") + return + for g in gpus: + uuid = g.get("uuid") or "N/A" + pci = g.get("pci_bus_id") + pci_str = f"PCI 0x{pci:04x}" if pci is not None else "PCI N/A" + print(f" [{g['index']}] {g['name']} — {uuid} — {pci_str}") + + +def cmd_setup(args): + """One-shot hardware compatibility check: diag → read → write-verify → restore.""" + explicit_point = getattr(args, "point", None) + verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0 + verify_delta_khz = int(verify_delta_mhz * 1000) + + require_root() + + from .hal.gpu import get_gpu + from .hal.vfcurve import read_curve, read_clock_offsets, write_offsets + from .hal.monitoring import read_voltage + from .hal.snapshot import save as snapshot_save, restore as snapshot_restore + + sep = "─" * 60 + + print(sep) + print(" NVCurve Setup — Hardware Compatibility Check") + print(sep) + print() + + gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0)) + + # ── Step 1: NvAPI diagnostics ────────────────────────────────────────────── + print("Step 1/4 NvAPI function probe") + print() + run_diagnostics(gpu, gpu_name, gpu_index=getattr(args, "gpu_index", 0)) + print() + + # ── Step 2: read current curve ───────────────────────────────────────────── + print("Step 2/4 Current V/F curve") + print() + curve_state, curve_err = read_curve(gpu, gpu_name) + if not curve_state: + print(f"FAILED to read V/F curve: {curve_err}", file=sys.stderr) + sys.exit(1) + voltage, _ = read_voltage(gpu) + pts = [(p.freq_khz, p.volt_uv) for p in curve_state.points] + offsets = [p.delta_khz for p in curve_state.points] + domains = [p.domain for p in curve_state.points] + print_curve(pts, offsets, voltage, domains=domains) + print() + + # Resolve verify point: explicit override, or last GPU-domain point. + gpu_points = [p for p in curve_state.points if p.domain == "gpu"] + if explicit_point is not None: + verify_point = explicit_point + elif gpu_points: + verify_point = gpu_points[-1].index + else: + verify_point = len(curve_state.points) - 1 + + # ── Step 3: write-verify cycle ───────────────────────────────────────────── + if verify_point >= len(curve_state.points): + print(f"Step 3/4 Write-verify (SKIPPED — point {verify_point} not present; " + f"GPU has {len(curve_state.points)} points)") + print() + print(sep) + print(" RESULT: Diagnostics passed. Write-verify skipped.") + print(f" Use --point to specify a valid point index (0–{len(curve_state.points) - 1}).") + print(sep) + return + + print(f"Step 3/4 Write-verify ({verify_delta_mhz:+.0f} MHz at point {verify_point})") + print() + + snap_path = snapshot_save(gpu, gpu_name, default_config.snapshot_dir, default_config.max_snapshots) + if snap_path: + print(f" Snapshot saved: {snap_path}") + + before_offsets, err = read_clock_offsets(gpu) + if before_offsets is None: + print(f" FAILED to read state before write: {err}", file=sys.stderr) + sys.exit(1) + + full_mask = getattr(args, "full_mask", False) + ret, desc = write_offsets(gpu, {verify_point: verify_delta_khz}, full_mask=full_mask) + if ret != 0: + print(f" Write FAILED ({ret}): {desc}") + print() + print(sep) + print(" RESULT: Write path is NOT working on this configuration.") + print(sep) + sys.exit(1) + + time.sleep(0.2) + + after_offsets, err = read_clock_offsets(gpu) + if after_offsets is None: + print(f" Verification read FAILED: {err}", file=sys.stderr) + sys.exit(1) + + actual = after_offsets[verify_point] + matched = actual == verify_delta_khz + collateral = [ + i for i in range(min(len(before_offsets), len(after_offsets))) + if i != verify_point and before_offsets[i] != after_offsets[i] + ] + + if matched: + print(f" Point {verify_point}: {verify_delta_khz / 1000:+.0f} MHz OK") + else: + print(f" Point {verify_point}: MISMATCH — expected {verify_delta_khz / 1000:+.0f} MHz, " + f"got {actual / 1000:+.0f} MHz") + if collateral: + print(f" WARNING: {len(collateral)} collateral point(s) changed: {collateral[:5]}") + else: + print(" No collateral changes") + + # ── Step 4: restore ──────────────────────────────────────────────────────── + print() + print("Step 4/4 Restoring snapshot") + print() + ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path) + if ok: + print(" Hardware state restored to baseline.") + else: + print(" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr) + + print() + print(sep) + if matched and not collateral: + print(" RESULT: Compatible. NVCurve is ready to use.") + print() + print(" Next: nvcurve launch web UI") + print(" nvcurve service install auto-start on boot") + elif not matched: + print(" RESULT: Write verification FAILED — this configuration is not supported.") + else: + print(" RESULT: Write applied but unexpected collateral changes detected.") + print(" Review the output above before using write operations.") + print(sep) + + +def cmd_service(args): + """Manage the nvcurve systemd service.""" + action = getattr(args, "action", None) + if not action: + print("Usage: nvcurve service [install|uninstall|start|stop|restart|status]") + return + + unit_path = "/etc/systemd/system/nvcurve.service" + + if action == "install": + require_root() + import subprocess + + exec_start = f"{sys.executable} -m nvcurve daemon" + + unit = ( + "[Unit]\n" + "Description=NVCurve NVIDIA GPU V/F Curve Daemon\n" + "After=nvidia-persistenced.service\n" + "Wants=nvidia-persistenced.service\n" + "\n" + "[Service]\n" + "Type=simple\n" + f"ExecStart={exec_start}\n" + "Restart=on-failure\n" + "RestartSec=5\n" + "Environment=PYTHONDONTWRITEBYTECODE=1\n" + "\n" + "[Install]\n" + "WantedBy=multi-user.target\n" + ) + + with open(unit_path, "w") as f: + f.write(unit) + print(f"Unit file written to {unit_path}") + + # Write persistent config. + os.makedirs("/etc/nvcurve", exist_ok=True) + persistent_cfg: dict = {} + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + persistent_cfg = json.load(f) + except Exception: + pass + host = getattr(args, "host", "127.0.0.1") + port = getattr(args, "port", 8042) + auto_serve = getattr(args, "auto_serve", False) + persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve}) + with open(_PERSISTENT_CONFIG_FILE, "w") as f: + json.dump(persistent_cfg, f, indent=2) + print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}") + if auto_serve: + print(f" Web server will auto-start on boot at {host}:{port}") + else: + print(f" Web server default: {host}:{port} (start on demand: nvcurve serve start)") + + try: + subprocess.run(["systemctl", "daemon-reload"], check=True) + + was_active = subprocess.run( + ["systemctl", "is-active", "--quiet", "nvcurve"], + ).returncode == 0 + + subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True) + print("Service enabled and started.") + + if was_active: + print() + print("Note: the service was already running and is still on the old version.") + print(" Restart it to pick up the update: nvcurve service restart") + + print() + print("Useful commands:") + print(" systemctl status nvcurve") + print(" journalctl -u nvcurve -f") + print(" nvcurve service uninstall") + except subprocess.CalledProcessError as e: + print(f"systemctl failed: {e}", file=sys.stderr) + + elif action == "uninstall": + require_root() + import subprocess + + if not os.path.exists(unit_path): + print("Service is not installed.") + return + try: + subprocess.run(["systemctl", "stop", "nvcurve"], check=False) + subprocess.run(["systemctl", "disable", "nvcurve"], check=False) + os.remove(unit_path) + if os.path.exists(_PERSISTENT_CONFIG_FILE): + os.remove(_PERSISTENT_CONFIG_FILE) + subprocess.run(["systemctl", "daemon-reload"], check=True) + print("Service stopped, disabled, and removed.") + except Exception as e: + print(f"Error during uninstall: {e}", file=sys.stderr) + + elif action == "start": + require_root() + import subprocess + if not os.path.exists(unit_path): + print("Service is not installed. Run: nvcurve service install") + return + try: + subprocess.run(["systemctl", "start", "nvcurve"], check=True) + print("Service started.") + except subprocess.CalledProcessError as e: + print(f"systemctl start failed: {e}", file=sys.stderr) + + elif action == "stop": + require_root() + import subprocess + if not os.path.exists(unit_path): + print("Service is not installed.") + return + try: + subprocess.run(["systemctl", "stop", "nvcurve"], check=True) + print("Service stopped.") + except subprocess.CalledProcessError as e: + print(f"systemctl stop failed: {e}", file=sys.stderr) + + elif action == "restart": + require_root() + import subprocess + if not os.path.exists(unit_path): + print("Service is not installed. Run: nvcurve service install") + return + try: + subprocess.run(["systemctl", "restart", "nvcurve"], check=True) + print("Service restarted.") + except subprocess.CalledProcessError as e: + print(f"systemctl restart failed: {e}", file=sys.stderr) + + elif action == "status": + import subprocess + + if os.path.exists(unit_path): + result = subprocess.run( + ["systemctl", "is-active", "nvcurve"], + capture_output=True, text=True, + ) + active = result.stdout.strip() + pid_info = "" + if active == "active": + r2 = subprocess.run( + ["systemctl", "show", "nvcurve", "--property=MainPID"], + capture_output=True, text=True, + ) + pid = r2.stdout.strip().replace("MainPID=", "") + if pid and pid != "0": + pid_info = f" (PID {pid})" + print(f"systemd service: {active}{pid_info}") + else: + print("systemd service: not installed") + print(f" (no unit file at {unit_path})") + print() + print("Register with: nvcurve service install") + + # Show persistent config regardless of install state. + pcfg: dict = {} + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + pcfg = json.load(f) + except FileNotFoundError: + pass + except Exception as e: + print(f" (could not read config: {e})", file=sys.stderr) + + auto_serve = pcfg.get("auto_serve", False) + host = pcfg.get("host", "127.0.0.1") + port = pcfg.get("port", 8042) + print() + print(f"web server auto-start: {'on' if auto_serve else 'off'}") + print(f"web server address: {host}:{port}") + print() + print("Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]") + + elif action == "configure": + require_root() + import subprocess + + os.makedirs("/etc/nvcurve", exist_ok=True) + pcfg: dict = {} + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + pcfg = json.load(f) + except Exception: + pass + + if hasattr(args, "auto_serve") and args.auto_serve is not None: + pcfg["auto_serve"] = args.auto_serve + if hasattr(args, "host") and args.host is not None: + pcfg["host"] = args.host + if hasattr(args, "port") and args.port is not None: + pcfg["port"] = args.port + + with open(_PERSISTENT_CONFIG_FILE, "w") as f: + json.dump(pcfg, f, indent=2) + print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):") + print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}") + print(f" host: {pcfg.get('host', '127.0.0.1')}") + print(f" port: {pcfg.get('port', 8042)}") + + if os.path.exists(unit_path): + try: + subprocess.run(["systemctl", "restart", "nvcurve"], check=True) + print("Daemon restarted — new config is active.") + except subprocess.CalledProcessError as e: + print(f"systemctl restart failed: {e}", file=sys.stderr) + else: + print("(Service not installed — config will take effect on next install.)") + + +# ── Server management ───────────────────────────────────────────────────────── + +def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None: + """Start the web server — via daemon if available, otherwise directly (requires root).""" + host = getattr(args, "host", cfg.host) + port = getattr(args, "port", cfg.port) + + # --direct: skip daemon round-trip (used when the daemon itself spawns us). + if getattr(args, "direct", False): + require_root() + with open(_SERVER_INFO_FILE, "w") as f: + json.dump({"pid": os.getpid(), "host": host, "port": port}, f) + try: + from .server import run as server_run + server_run(host=host, port=port, + gpu_index=getattr(args, "gpu_index", 0), + config=cfg, open_browser=False) + finally: + if os.path.exists(_SERVER_INFO_FILE): + os.remove(_SERVER_INFO_FILE) + return + + # Prefer daemon socket: no root required, daemon manages the server process. + resp = _daemon_send({"cmd": "serve_start", "host": host, "port": port}) + if resp is not None: + if resp.get("ok"): + print(f"Web server starting (PID {resp['pid']}) at http://{host}:{port}") + if open_browser: + time.sleep(1.5) + _open_browser_as_user(f"http://{host}:{port}") + else: + print(f"Daemon: {resp.get('error')}", file=sys.stderr) + return + + # Daemon not running — fall back to direct start (requires root). + require_root() + + info = _read_server_info() + if info: + url = f"http://{info['host']}:{info['port']}" + print(f"Server is already running (PID {info['pid']}) at {url}.") + if open_browser: + _open_browser_as_user(url) + return + + if getattr(args, "detach", False): + import subprocess + cmd = [sys.executable, "-m", "nvcurve", "serve", "start", + "--host", host, "--port", str(port)] + if getattr(args, "gpu_index", 0): + cmd += ["--gpu", str(args.gpu_index)] + log_path = _log_file() + print("Starting nvcurve server in background...") + with open(log_path, "a") as lf: + p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True) + print(f"Server starting (PID {p.pid}). Logs: {log_path}") + if open_browser: + time.sleep(1.5) + _open_browser_as_user(_discover_server_url(cfg)) + return + + # Foreground mode — write info file so clients can discover host:port. + with open(_SERVER_INFO_FILE, "w") as f: + json.dump({"pid": os.getpid(), "host": host, "port": port}, f) + try: + from .server import run as server_run + server_run( + host=host, + port=port, + gpu_index=getattr(args, "gpu_index", 0), + config=cfg, + open_browser=open_browser, + ) + finally: + if os.path.exists(_SERVER_INFO_FILE): + os.remove(_SERVER_INFO_FILE) + + +# ── Argument parser ─────────────────────────────────────────────────────────── + +def build_parser() -> argparse.ArgumentParser: + from importlib.metadata import version as pkg_version + try: + __version__ = pkg_version("nvcurve") + except Exception: + __version__ = "unknown" + + parser = argparse.ArgumentParser( + prog="nvcurve", + description="Read/Write NVIDIA GPU V/F curve via undocumented NvAPI (Linux)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + %(prog)s Launch web UI (default) + %(prog)s read Condensed V/F curve + %(prog)s read --full All points + %(prog)s read --json JSON output + %(prog)s write --global --delta 50 +50 MHz to all points + %(prog)s write --point 80 --delta 100 +100 MHz to point 80 + %(prog)s write --reset Reset all offsets to 0 + %(prog)s verify --point 80 --delta 15 Write + verify cycle + %(prog)s snapshot save/restore/list Manage snapshots + %(prog)s profile save balanced Save current state as profile + %(prog)s profile apply balanced Apply saved profile (escalates to root) + %(prog)s profile default balanced Set profile to auto-load on daemon start + %(prog)s serve start --detach Start web server in background + %(prog)s serve stop Stop running web server + %(prog)s service install Register daemon as systemd service (recommended) + %(prog)s read --diag Probe all NvAPI functions (needs root) + %(prog)s inspect --point 80 Raw buffer fields for a point (needs root) +""", + ) + parser.add_argument("-v", "--version", action="version", version=f"nvcurve {__version__}") + parser.add_argument( + "--server", default=None, metavar="URL", + help="Server base URL (default: http://127.0.0.1:8042)", + ) + parser.add_argument( + "--gpu", type=int, default=0, dest="gpu_index", + help="GPU index to target (default: 0)", + ) + sub = parser.add_subparsers(dest="command") + + # read + p_read = sub.add_parser("read", help="Read V/F curve") + p_read.add_argument("--full", action="store_true", help="Show all points") + p_read.add_argument("--json", action="store_true", help="JSON output") + p_read.add_argument("--raw", action="store_true", + help="Raw hex dumps of hardware buffers (needs root)") + p_read.add_argument("--diag", action="store_true", + help="Probe all NvAPI functions (needs root)") + + # inspect + p_insp = sub.add_parser("inspect", + help="Show raw ClockBoostTable buffer fields (needs root)") + p_insp.add_argument("--point", type=int, help="Single point index") + p_insp.add_argument("--range", type=parse_range, help="Point range A-B") + + # write + p_write = sub.add_parser("write", help="Write frequency offsets") + tgt = p_write.add_mutually_exclusive_group() + tgt.add_argument("--point", type=int, help="Single point index") + tgt.add_argument("--range", type=parse_range, help="Point range A-B") + tgt.add_argument("--global", dest="glob", action="store_true", + help="All points (like global NVML offset)") + tgt.add_argument("--reset", action="store_true", help="Reset all offsets to 0") + p_write.add_argument("--delta", type=float, default=0.0, + help="Frequency offset in MHz (e.g. 15, -30)") + p_write.add_argument("--dry-run", action="store_true", + help="Preview changes without applying") + p_write.add_argument("--max-delta", type=float, default=None, + help="Override safety limit for this write (MHz)") + + # verify + p_ver = sub.add_parser("verify", help="Write-verify-read cycle") + p_ver.add_argument("--point", type=int, help="Single point index") + p_ver.add_argument("--range", type=parse_range, help="Point range A-B") + p_ver.add_argument("--delta", type=float, required=True, + help="Frequency offset in MHz") + + # setup + p_setup = sub.add_parser("setup", + help="Hardware compatibility check: diag → read → write-verify → restore (needs root)") + p_setup.add_argument("--point", type=int, default=None, + help="Point index to use for write-verify test (default: last GPU-domain point)") + p_setup.add_argument("--delta", type=float, default=5.0, + help="Offset in MHz to use for write-verify test (default: +5)") + p_setup.add_argument("--full-mask", action="store_true", + help="Use the full GetClockBoostMask instead of a sparse mask " + "(try this if writes fail on older GPUs such as Pascal)") + + # snapshot + p_snap = sub.add_parser("snapshot", + help="Save/restore/list ClockBoostTable snapshots") + p_snap.add_argument("action", choices=["save", "restore", "list"]) + p_snap.add_argument("--file", help="Snapshot file path (for restore)") + + # gpus + sub.add_parser("gpus", help="List detected NVIDIA GPUs (server-optional)") + + # profile + p_prof = sub.add_parser("profile", help="Manage saved clock/limit profiles (server-optional)") + p_prof.add_argument("action", choices=["save", "apply", "list", "default"]) + p_prof.add_argument("name", nargs="?", help="Profile name (for save/apply/default)") + p_prof.add_argument("--clear", action="store_true", help="Clear the default profile (for default action)") + + # daemon + sub.add_parser("daemon", help="Run the nvcurve daemon (apply auto-load profiles, requires root)") + + # autoload + sub.add_parser("autoload", help="Apply auto-load profiles from config (requires root)") + + # serve + p_srv = sub.add_parser("serve", help="Start or manage the web server") + s_srv = p_srv.add_subparsers(dest="action") + + p_start = s_srv.add_parser("start", help="Start the server (escalates to root)") + p_start.add_argument("--host", default="127.0.0.1", + help="Bind address (default 127.0.0.1)") + p_start.add_argument("--port", type=int, default=8042, + help="Port (default 8042)") + p_start.add_argument("--detach", "-d", action="store_true", + help="Run in background") + p_start.add_argument("--direct", action="store_true", + help=argparse.SUPPRESS) # internal: skip daemon check + + s_srv.add_parser("stop", help="Stop the running server") + s_srv.add_parser("status", help="Check server status") + + # service + p_svc = sub.add_parser("service", help="Manage the nvcurve systemd service") + s_svc = p_svc.add_subparsers(dest="action") + + p_install = s_svc.add_parser("install", + help="Register as systemd service (escalates to root)") + p_install.add_argument("--auto-serve", action="store_true", dest="auto_serve", + help="Auto-start web server on boot (default: off)") + p_install.add_argument("--host", default="127.0.0.1", + help="Default web server bind address (stored in config)") + p_install.add_argument("--port", type=int, default=8042, + help="Default web server port (stored in config)") + + p_configure = s_svc.add_parser("configure", + help="Update config and restart daemon (escalates to root)") + p_configure.add_argument("--auto-serve", dest="auto_serve", + action="store_true", default=None, + help="Auto-start web server on boot") + p_configure.add_argument("--no-auto-serve", dest="auto_serve", + action="store_false", + help="Do not auto-start web server on boot") + p_configure.add_argument("--host", default=None, + help="Web server bind address") + p_configure.add_argument("--port", type=int, default=None, + help="Web server port") + + s_svc.add_parser("uninstall", + help="Remove systemd service (escalates to root)") + s_svc.add_parser("start", + help="Start systemd service (escalates to root)") + s_svc.add_parser("stop", + help="Stop systemd service (escalates to root)") + s_svc.add_parser("restart", + help="Restart systemd service (escalates to root)") + s_svc.add_parser("status", help="Check systemd service status") + + return parser + + +def main(): + import sys + base_parser = argparse.ArgumentParser(add_help=False) + base_parser.add_argument("--server", default=None) + base_parser.add_argument("--gpu", type=int, default=0, dest="gpu_index") + + known_args, remaining_argv = base_parser.parse_known_args(sys.argv[1:]) + + parser = build_parser() + args = parser.parse_args(remaining_argv) + + if known_args.server is not None: + args.server = known_args.server + if known_args.gpu_index != 0: + args.gpu_index = known_args.gpu_index + + cfg = Config() + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + data = json.load(f) + for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", + "snapshot_dir", "profile_dir", "host", "port"): + if key in data: + setattr(cfg, key, data[key]) + if "auto_load_profiles" in data: + # Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N") + cfg.auto_load_profiles = dict(data["auto_load_profiles"]) + elif "auto_load_profile" in data: + # Migrate old single-string format — GPU 0, no UUID known at this point + cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]} + except Exception: + pass + + base_url = args.server or _discover_server_url(cfg) + client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0)) + + # Default — no subcommand: open the web UI. + # If the server is already running, just open a browser tab (no root needed). + # Otherwise start it. If we started it via the daemon (non-blocking), we + # block here and stop the server when the user hits Ctrl+C. + if args.command is None: + if client.ping(): + _open_browser_as_user(base_url) + return + + via_daemon = _daemon_send({"cmd": "ping"}) is not None + _cmd_serve_start(args, cfg, open_browser=True) + + if via_daemon: + print("Press Ctrl+C to stop.") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print() + finally: + _daemon_send({"cmd": "serve_stop"}) + return + + if args.command == "serve": + action = getattr(args, "action", None) or "start" + + if action == "start": + _cmd_serve_start(args, cfg, open_browser=False) + + elif action == "stop": + # Try daemon socket first. + resp = _daemon_send({"cmd": "serve_stop"}) + if resp is not None: + if resp.get("ok"): + print("Web server stopped.") + else: + print(f"Daemon: {resp.get('error')}", file=sys.stderr) + return + # Fallback: send shutdown via HTTP API. + try: + client.shutdown() + print("Server stopped.") + except ServerNotRunning: + print("Server is not running.") + if os.path.exists(_SERVER_INFO_FILE): + try: + os.remove(_SERVER_INFO_FILE) + except OSError: + pass + except ApiError as e: + print(f"Shutdown failed: {e.detail}", file=sys.stderr) + + elif action == "status": + # Try daemon socket first. + resp = _daemon_send({"cmd": "serve_status"}) + if resp is not None: + if resp.get("running"): + pid = resp.get("pid") + print(f"Daemon: web server running (PID {pid}) at {base_url}") + else: + print(f"Daemon: web server not running") + return + # Fallback: check HTTP directly. + if client.ping(): + try: + info = client.gpu() + print(f"Server is running at {base_url}") + print(f" GPU: {info.get('name', '?')}") + driver = info.get("driver_version") + if driver: + print(f" Driver: {driver}") + except Exception: + print(f"Server is running at {base_url}") + else: + print(f"Server is NOT running at {base_url}") + return + + if args.command == "daemon": + from .daemon import run as daemon_run + daemon_run() + return + + if args.command == "autoload": + from .profiles.apply import run_autoload + run_autoload() + return + + if args.command == "setup": + cmd_setup(args) + elif args.command == "read": + cmd_read(args) + elif args.command == "inspect": + cmd_inspect(args) + elif args.command == "write": + cmd_write(args) + elif args.command == "verify": + cmd_verify(args) + elif args.command == "snapshot": + cmd_snapshot(args) + elif args.command == "gpus": + cmd_gpus(args) + elif args.command == "profile": + cmd_profile(args) + elif args.command == "service": + cmd_service(args) diff --git a/nvcurve/client.py b/nvcurve/client.py new file mode 100644 index 0000000..cd3f243 --- /dev/null +++ b/nvcurve/client.py @@ -0,0 +1,148 @@ +"""HTTP client for communicating with a running nvcurve server.""" + +import httpx +from typing import Any + +DEFAULT_BASE = "http://127.0.0.1:8042" +_TIMEOUT = 5.0 + + +class ServerNotRunning(Exception): + """Raised when the nvcurve server cannot be reached.""" + + +class ApiError(Exception): + def __init__(self, status_code: int, detail: Any): + self.status_code = status_code + self.detail = detail + super().__init__(f"HTTP {status_code}: {detail}") + + +class NvCurveClient: + def __init__(self, base: str = DEFAULT_BASE, gpu_index: int = 0): + self._base = base.rstrip("/") + self.gpu_index = gpu_index + + def _url(self, path: str) -> str: + sep = "&" if "?" in path else "?" + return f"{self._base}{path}{sep}gpu_index={self.gpu_index}" + + def _raise(self, r: httpx.Response) -> None: + if r.is_error: + try: + detail = r.json().get("detail", r.text) + except Exception: + detail = r.text + raise ApiError(r.status_code, detail) + + def _get(self, path: str) -> Any: + try: + r = httpx.get(self._url(path), timeout=_TIMEOUT) + except httpx.ConnectError: + raise ServerNotRunning() + self._raise(r) + return r.json() + + def _post(self, path: str, body: Any = None) -> Any: + try: + r = httpx.post(self._url(path), json=body, timeout=_TIMEOUT) + except httpx.ConnectError: + raise ServerNotRunning() + self._raise(r) + return r.json() + + def _delete(self, path: str) -> Any: + try: + r = httpx.delete(self._url(path), timeout=_TIMEOUT) + except httpx.ConnectError: + raise ServerNotRunning() + self._raise(r) + return r.json() + + def ping(self) -> bool: + try: + httpx.get(self._url("/api/gpu"), timeout=1.0) + return True + except Exception: + return False + + def gpus(self) -> list: + return self._get("/api/gpus") + + # ── GPU ────────────────────────────────────────────────────────────────── + + def gpu(self) -> dict: + return self._get("/api/gpu") + + # ── Curve ──────────────────────────────────────────────────────────────── + + def curve(self) -> dict: + """Returns {gpu_name, timestamp, points: [{index, freq_khz, volt_uv, delta_khz, ...}]}""" + return self._get("/api/curve") + + def voltage(self) -> int | None: + """Returns current GPU voltage in µV, or None if unavailable.""" + try: + return self._get("/api/voltage")["voltage_uv"] + except Exception: + return None + + def write_curve( + self, + deltas: dict[int, int], + max_delta_khz: int | None = None, + ) -> dict: + body: dict = {"deltas": deltas} + if max_delta_khz is not None: + body["max_delta_khz"] = max_delta_khz + return self._post("/api/curve/write", body) + + def write_global(self, delta_khz: int, max_delta_khz: int | None = None) -> dict: + body: dict = {"delta_khz": delta_khz} + if max_delta_khz is not None: + body["max_delta_khz"] = max_delta_khz + return self._post("/api/curve/write/global", body) + + def reset_curve(self) -> dict: + return self._post("/api/curve/reset") + + def verify_write(self, deltas: dict[int, int]) -> dict: + return self._post("/api/curve/verify", {"deltas": deltas}) + + # ── Snapshots ──────────────────────────────────────────────────────────── + + def snapshot_save(self) -> dict: + return self._post("/api/snapshot/save") + + def snapshot_restore(self, filepath: str | None = None) -> dict: + return self._post("/api/snapshot/restore", {"filepath": filepath}) + + def snapshots(self) -> list: + return self._get("/api/snapshots") + + # ── Profiles ───────────────────────────────────────────────────────────── + + def profiles(self) -> dict: + return self._get("/api/profiles") + + def profile_save(self, name: str) -> dict: + return self._post("/api/profiles", {"name": name}) + + def profile_apply(self, name: str) -> dict: + return self._post(f"/api/profiles/{name}/apply") + + def profile_delete(self, name: str) -> dict: + return self._delete(f"/api/profiles/{name}") + + # ── Config ─────────────────────────────────────────────────────────────── + + def config_get(self) -> dict: + return self._get("/api/config") + + def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict: + return self._post("/api/config", {"auto_load_profile": auto_load_profile, "gpu_index": gpu_index}) + + # ── Server control ─────────────────────────────────────────────────────── + + def shutdown(self) -> dict: + return self._post("/api/shutdown") diff --git a/nvcurve/config.py b/nvcurve/config.py new file mode 100644 index 0000000..a7ec634 --- /dev/null +++ b/nvcurve/config.py @@ -0,0 +1,31 @@ +"""User-configurable settings with sensible defaults.""" + +import os +from dataclasses import dataclass, field + + +@dataclass +class Config: + # Safety limits + max_delta_khz: int = 3000_000 # ±3000 MHz hard cap + auto_snapshot: bool = True # Save snapshot before every write + max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited) + + # Monitoring + poll_interval_s: float = 1.0 # WebSocket monitor poll rate + + # API server + host: str = "127.0.0.1" + port: int = 8042 + + snapshot_dir: str = "/var/cache/nvcurve/snapshots" + profile_dir: str = "/etc/nvcurve/profiles" + + # Per-GPU default profiles: applied automatically on server startup. + # Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback). + # Value = profile name (str). + auto_load_profiles: dict[str, str] = field(default_factory=dict) + + +# Module-level default config instance. +default_config = Config() diff --git a/nvcurve/daemon.py b/nvcurve/daemon.py new file mode 100644 index 0000000..7ca7cbd --- /dev/null +++ b/nvcurve/daemon.py @@ -0,0 +1,209 @@ +"""Minimal daemon: apply auto-load profiles on boot, manage web server via Unix socket. + +Run via: nvcurve daemon +Or via systemd: nvcurve service install + +Protocol: newline-delimited JSON, one request → one response, connection closed. + +Commands: + {"cmd": "ping"} + {"cmd": "serve_start", "host": "127.0.0.1", "port": 8042} + {"cmd": "serve_stop"} + {"cmd": "serve_status"} + +Requires root. +""" + +import asyncio +import json +import logging +import os +import signal +import subprocess +import sys + +log = logging.getLogger("nvcurve.daemon") + +SOCKET_PATH = "/run/nvcurve-daemon.sock" +_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json" + +# Global server subprocess — only touched from the asyncio event loop. +_server_proc: subprocess.Popen | None = None +_cfg = None # Config instance, set in run() + + +# ── Socket command handlers ──────────────────────────────────────────────────── + +async def _handle_serve_start(host: str, port: int) -> dict: + global _server_proc + if _server_proc is not None and _server_proc.poll() is None: + return {"ok": False, "error": "web server already running", "pid": _server_proc.pid} + + cmd = [sys.executable, "-m", "nvcurve", "serve", "start", + "--host", host, "--port", str(port), "--direct"] + log_path = "/var/log/nvcurve-server.log" + log.info("Starting web server on %s:%d (log: %s)", host, port, log_path) + with open(log_path, "a") as lf: + _server_proc = subprocess.Popen( + cmd, + stdout=lf, + stderr=lf, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + log.info("Web server started (PID %d)", _server_proc.pid) + return {"ok": True, "pid": _server_proc.pid} + + +async def _handle_serve_stop() -> dict: + global _server_proc + if _server_proc is None or _server_proc.poll() is not None: + _server_proc = None + return {"ok": False, "error": "web server is not running"} + log.info("Stopping web server (PID %d)…", _server_proc.pid) + _server_proc.terminate() + try: + await asyncio.get_running_loop().run_in_executor( + None, _server_proc.wait, 5 + ) + except Exception: + _server_proc.kill() + _server_proc = None + return {"ok": True} + + +async def _handle_serve_status() -> dict: + global _server_proc + if _server_proc is not None and _server_proc.poll() is None: + return {"ok": True, "running": True, "pid": _server_proc.pid} + _server_proc = None + return {"ok": True, "running": False} + + +async def _dispatch(req: dict) -> dict: + cmd = req.get("cmd") + if cmd == "ping": + return {"ok": True} + elif cmd == "serve_start": + host = req.get("host", _cfg.host) + port = req.get("port", _cfg.port) + return await _handle_serve_start(host, port) + elif cmd == "serve_stop": + return await _handle_serve_stop() + elif cmd == "serve_status": + return await _handle_serve_status() + else: + return {"ok": False, "error": f"unknown command: {cmd!r}"} + + +async def _handle_client( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter +) -> None: + resp: dict = {"ok": False, "error": "internal error"} + try: + data = await asyncio.wait_for(reader.readline(), timeout=5.0) + req = json.loads(data) + resp = await _dispatch(req) + except asyncio.TimeoutError: + resp = {"ok": False, "error": "timeout reading request"} + except json.JSONDecodeError as exc: + resp = {"ok": False, "error": f"invalid JSON: {exc}"} + except Exception as exc: + resp = {"ok": False, "error": str(exc)} + finally: + try: + writer.write(json.dumps(resp).encode() + b"\n") + await writer.drain() + except Exception: + pass + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + +# ── Entrypoint ───────────────────────────────────────────────────────────────── + +def _load_persistent_config() -> dict: + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + return json.load(f) + except Exception: + return {} + + +def run() -> None: + """Run the nvcurve daemon: apply auto-load profiles, then serve the Unix socket.""" + global _cfg + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + if os.geteuid() != 0: + print("nvcurve daemon: must run as root", file=sys.stderr) + sys.exit(1) + + cfg_data = _load_persistent_config() + + from .config import Config + _cfg = Config() + for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", + "snapshot_dir", "profile_dir", "host", "port"): + if key in cfg_data: + setattr(_cfg, key, cfg_data[key]) + + # Apply auto-load profiles in a subprocess so the daemon process itself + # never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low. + auto_load_profiles: dict = cfg_data.get("auto_load_profiles", {}) + if auto_load_profiles: + log.info("Applying auto-load profiles…") + subprocess.run( + [sys.executable, "-m", "nvcurve", "autoload"], + check=False, + ) + + auto_serve: bool = cfg_data.get("auto_serve", False) + asyncio.run(_serve_socket(auto_serve=auto_serve)) + + log.info("Daemon stopped.") + + +async def _serve_socket(auto_serve: bool = False) -> None: + global _server_proc + + # Clean up stale socket from a previous (unclean) run. + if os.path.exists(SOCKET_PATH): + os.unlink(SOCKET_PATH) + + server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH) + os.chmod(SOCKET_PATH, 0o666) # allow non-root CLI to connect + log.info("Daemon listening on %s", SOCKET_PATH) + + if auto_serve: + log.info("auto_serve enabled — starting web server on boot") + await _handle_serve_start(_cfg.host, _cfg.port) + + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGTERM, stop_event.set) + loop.add_signal_handler(signal.SIGINT, stop_event.set) + + async with server: + await stop_event.wait() + + # Gracefully stop the web server if it's running. + if _server_proc is not None and _server_proc.poll() is None: + log.info("Stopping web server (PID %d)…", _server_proc.pid) + _server_proc.terminate() + try: + await asyncio.wait_for( + asyncio.get_running_loop().run_in_executor(None, _server_proc.wait), + timeout=5, + ) + except asyncio.TimeoutError: + _server_proc.kill() + + if os.path.exists(SOCKET_PATH): + os.unlink(SOCKET_PATH) diff --git a/nvcurve/hal/__init__.py b/nvcurve/hal/__init__.py new file mode 100644 index 0000000..c7b3979 --- /dev/null +++ b/nvcurve/hal/__init__.py @@ -0,0 +1,13 @@ +from .gpu import get_gpu, discover_gpus +from .vfcurve import read_curve, read_clock_offsets, write_offsets, reset_offsets +from .monitoring import poll, read_voltage +from .ranges import get_clock_ranges +from .snapshot import save as snapshot_save, restore as snapshot_restore, list_snapshots + +__all__ = [ + "get_gpu", "discover_gpus", + "read_curve", "read_clock_offsets", "write_offsets", "reset_offsets", + "poll", "read_voltage", + "get_clock_ranges", + "snapshot_save", "snapshot_restore", "list_snapshots", +] diff --git a/nvcurve/hal/gpu.py b/nvcurve/hal/gpu.py new file mode 100644 index 0000000..5e9b1ca --- /dev/null +++ b/nvcurve/hal/gpu.py @@ -0,0 +1,93 @@ +"""GPU discovery and initialization.""" + +import ctypes +import sys + +from ..nvapi.bootstrap import query_interface +from ..nvapi.constants import FUNC +from ..nvapi.types import GpuInfo + + +def init_nvapi() -> None: + """Initialize NvAPI. Must be called before any GPU operations.""" + init_fn = query_interface(FUNC["Initialize"], nargs=0) + if not init_fn or init_fn() != 0: + print("NvAPI_Initialize failed") + sys.exit(1) + + +def enumerate_gpus() -> tuple[ctypes.Array, int]: + """Return (gpu_handles_array, count). Exits if no GPUs found.""" + gpus = (ctypes.c_void_p * 64)() + ngpu = ctypes.c_int32() + query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu)) + if ngpu.value == 0: + print("No NVIDIA GPUs found") + sys.exit(1) + return gpus, ngpu.value + + +def get_gpu_name(gpu) -> str: + """Return the full name string for a GPU handle.""" + name_buf = ctypes.create_string_buffer(256) + query_interface(FUNC["GetFullName"])(gpu, name_buf) + return name_buf.value.decode(errors="replace") + + +def discover_gpus() -> list[GpuInfo]: + """Initialize NvAPI and NVML, and return a list of GpuInfo for all physical GPUs.""" + init_nvapi() + gpus, count = enumerate_gpus() + infos = [] + + try: + import pynvml + pynvml.nvmlInit() + has_nvml = True + except Exception: + has_nvml = False + + for i in range(count): + name = get_gpu_name(gpus[i]) + uuid = None + pci_bus_id = None + if has_nvml: + try: + handle = pynvml.nvmlDeviceGetHandleByIndex(i) + uuid = pynvml.nvmlDeviceGetUUID(handle) + # NVML might return bytes + if isinstance(uuid, bytes): + uuid = uuid.decode('utf-8', errors='ignore') + pci_info = pynvml.nvmlDeviceGetPciInfo(handle) + # Parse something like "00000000:01:00.0" -> bus is 1 + if isinstance(pci_info.bus, bytes): + pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16) + else: + pci_bus_id = pci_info.bus + except Exception: + pass + infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id)) + + if has_nvml: + try: + pynvml.nvmlShutdown() + except Exception: + pass + + return infos + + +def get_gpu(index: int = 0): + """Initialize NvAPI, enumerate GPUs, and return the handle for `index`. + + Also returns the GPU name as a convenience. + Returns (handle, name). + """ + init_nvapi() + gpus, count = enumerate_gpus() + if index >= count: + print(f"GPU index {index} out of range (found {count} GPU(s))") + sys.exit(1) + gpu = gpus[index] + name = get_gpu_name(gpu) + return gpu, name diff --git a/nvcurve/hal/limits.py b/nvcurve/hal/limits.py new file mode 100644 index 0000000..6971823 --- /dev/null +++ b/nvcurve/hal/limits.py @@ -0,0 +1,321 @@ +"""Hardware Abstraction Layer for Global Limits (Power, Clock Offsets). + +Uses NVML (via pynvml) for all operations. + +Clock offsets use nvmlDeviceSetClockOffsets / nvmlDeviceGetClockOffsets +(introduced in driver 555.85). The older per-domain functions +(nvmlDeviceSet/GetGpcClkVfOffset, nvmlDeviceSet/GetMemClkVfOffset) are used +as a fallback when the new API is unavailable or returns an error. +set_clock_offsets accepts Optional values and only touches the domains +that are explicitly specified, leaving others unchanged on hardware. +""" + +import ctypes +import subprocess +import logging +from typing import Optional + +try: + import pynvml + _NVML_AVAILABLE = True +except ImportError: + _NVML_AVAILABLE = False + +log = logging.getLogger("nvcurve.hal.limits") + +# ── NVML library / handle helpers ───────────────────────────────────────────── + +_nvml_lib: Optional[ctypes.CDLL] = None + + +def _nvml_cdll() -> ctypes.CDLL: + """Return a ctypes handle to libnvidia-ml, reusing pynvml's load if possible.""" + global _nvml_lib + if _nvml_lib is not None: + return _nvml_lib + # Prefer to reuse the library already loaded by pynvml to avoid dlopen races. + for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version + mod = getattr(pynvml, attr, None) + lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None) + if lib is not None: + _nvml_lib = lib + return _nvml_lib + _nvml_lib = ctypes.CDLL("libnvidia-ml.so.1") + return _nvml_lib + + +def _get_handle(gpu_index: int): + """Return an NVML device handle, initialising pynvml if needed.""" + if not _NVML_AVAILABLE: + raise RuntimeError("NVML not available (install nvidia-ml-py)") + return pynvml.nvmlDeviceGetHandleByIndex(gpu_index) + + +# ── Power limit ─────────────────────────────────────────────────────────────── + +def get_power_limit(gpu_index: int = 0) -> dict: + """Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w.""" + out = { + "power_limit_w": None, + "default_power_limit_w": None, + "min_power_limit_w": None, + "max_power_limit_w": None, + } + try: + handle = _get_handle(gpu_index) + limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle) + constrs = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle) + out["power_limit_w"] = limit // 1000 + out["min_power_limit_w"] = constrs[0] // 1000 + out["max_power_limit_w"] = constrs[1] // 1000 + try: + default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle) + out["default_power_limit_w"] = default // 1000 + except Exception: + pass + except Exception as exc: + log.warning("get_power_limit: %s", exc) + return out + + +def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]: + """Set the board power limit (Watts).""" + try: + handle = _get_handle(gpu_index) + pynvml.nvmlDeviceSetPowerManagementLimit(handle, limit_w * 1000) + return True, "OK" + except Exception as exc: + log.debug("NVML set_power_limit failed: %s — falling back to nvidia-smi", exc) + + ret = subprocess.run( + ["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)], + capture_output=True, text=True, + ) + if ret.returncode == 0: + return True, "OK" + return False, ret.stderr.strip() or ret.stdout.strip() + + +# ── Clock offsets (GPC + memory) ────────────────────────────────────────────── + +# The correct struct layout (per NVML docs and driver 590.x headers): +# +# typedef struct { +# unsigned int version; // nvmlClockOffset_v1 +# nvmlClockType_t type; // NVML_CLOCK_GRAPHICS (0) or NVML_CLOCK_MEM (2) +# nvmlPstates_t pstate; // NVML_PSTATE_0 (0) +# int clockOffsetMHz; +# } nvmlClockOffset_t; +# +# nvmlDeviceSet/GetClockOffsets are called ONCE PER CLOCK DOMAIN. +# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1 +# as ctypes objects; we use them when available and fall back to our own definition. + +class _ClockOffset(ctypes.Structure): + _fields_ = [ + ("version", ctypes.c_uint), + ("type", ctypes.c_uint), # nvmlClockType_t + ("pstate", ctypes.c_uint), # nvmlPstates_t + ("clockOffsetMHz", ctypes.c_int), + ] + +_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes) + +# NVML clock-type constants (same values as pynvml). +_NVML_CLOCK_GRAPHICS = 0 +_NVML_CLOCK_MEM = 2 + + +def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure: + """Return a populated nvmlClockOffset_t struct, using pynvml's type when available.""" + if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"): + info = pynvml.c_nvmlClockOffset_t() + info.version = pynvml.nvmlClockOffset_v1 + info.type = clock_type + info.pstate = pstate + info.clockOffsetMHz = offset_mhz + return info + info = _ClockOffset() + info.version = _CLOCK_OFFSET_VER + info.type = clock_type + info.pstate = pstate + info.clockOffsetMHz = offset_mhz + return info + + +def _try_nvml_fn(name: str): + """Return a ctypes-callable for an NVML function, or None if not found.""" + lib = _nvml_cdll() + try: + return getattr(lib, name) + except AttributeError: + return None + + +def get_clock_offsets(gpu_index: int = 0) -> dict: + """Return GPC and memory clock offsets (MHz). + + Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure). + Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM). + """ + out = {"gpc_offset_mhz": None, "mem_offset_mhz": None} + if not _NVML_AVAILABLE: + return out + try: + handle = _get_handle(gpu_index) + + # Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly). + # Fall back to ctypes-direct if pynvml doesn't have it. + _pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None) + fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None + + used_new_api = False + for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"), + (_NVML_CLOCK_MEM, "mem_offset_mhz")): + info = _make_clock_offset(clock_type, pstate=0) + try: + if _pynvml_get is not None: + rc = _pynvml_get(handle, ctypes.byref(info)) + elif fn_get is not None: + rc = fn_get(handle, ctypes.byref(info)) + else: + break + if rc == 0: + out[key] = int(info.clockOffsetMHz) + used_new_api = True + else: + log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc) + except Exception as exc: + log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc) + + if used_new_api: + return out + + # Deprecated per-domain fallback. + if hasattr(pynvml, "nvmlDeviceGetGpcClkVfOffset"): + try: + out["gpc_offset_mhz"] = int(pynvml.nvmlDeviceGetGpcClkVfOffset(handle)) + except Exception as exc: + log.debug("nvmlDeviceGetGpcClkVfOffset: %s", exc) + if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"): + try: + res = pynvml.nvmlDeviceGetMemClkVfOffset(handle) + out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res) + except Exception as exc: + log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc) + + except Exception as exc: + log.warning("get_clock_offsets: %s", exc) + return out + + +def set_clock_offsets( + gpc_offset_mhz: Optional[int] = None, + mem_offset_mhz: Optional[int] = None, + gpu_index: int = 0, +) -> tuple[bool, str]: + """Set clock offsets (MHz) for the specified domains only. + + Pass None for a domain to leave it untouched on hardware. + Calls nvmlDeviceSetClockOffsets once per requested domain (GRAPHICS, MEM). + Falls back to deprecated per-domain functions when the new API returns an + error (e.g. NVML_ERROR_DEPRECATED=25 on Blackwell with driver 590.x). + """ + if gpc_offset_mhz is None and mem_offset_mhz is None: + return True, "OK" + if not _NVML_AVAILABLE: + return False, "NVML not available (install nvidia-ml-py)" + try: + handle = _get_handle(gpu_index) + + domains = [] + if gpc_offset_mhz is not None: + domains.append((_NVML_CLOCK_GRAPHICS, gpc_offset_mhz)) + if mem_offset_mhz is not None: + domains.append((_NVML_CLOCK_MEM, mem_offset_mhz)) + + _pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None) + fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None + + if _pynvml_set is not None or fn_set is not None: + all_ok = True + for clock_type, offset in domains: + info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset) + try: + rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info)) + if rc != 0: + log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc) + all_ok = False + break + except Exception as exc: + log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc) + all_ok = False + break + if all_ok: + return True, "OK" + # Non-zero rc (e.g. 25=DEPRECATED on Blackwell) — fall through to deprecated path. + + # Deprecated per-domain fallback (works on Blackwell/driver 590.x). + errs = [] + if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"): + try: + pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz) + except Exception as exc: + errs.append(f"GPC: {exc}") + if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"): + try: + pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz) + except Exception as exc: + errs.append(f"MEM: {exc}") + if errs: + return False, "; ".join(errs) + return True, "OK" + + except Exception as exc: + log.warning("set_clock_offsets: %s", exc) + return False, str(exc) + + +# ── Range queries ───────────────────────────────────────────────────────────── + +def get_mem_offset_range(gpu_index: int = 0) -> dict: + """Return the min/max allowed memory clock offset (MHz). + + Keys: min_mem_offset_mhz, max_mem_offset_mhz. + Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values. + """ + # Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000). + out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000} + if not _NVML_AVAILABLE: + return out + try: + handle = _get_handle(gpu_index) + + if hasattr(pynvml, "nvmlDeviceGetMemClkMinMaxVfOffset"): + result = pynvml.nvmlDeviceGetMemClkMinMaxVfOffset(handle) + if isinstance(result, (list, tuple)) and len(result) >= 2: + out["min_mem_offset_mhz"] = int(result[0]) + out["max_mem_offset_mhz"] = int(result[1]) + else: + min_v = getattr(result, "minOffset", None) + max_v = getattr(result, "maxOffset", None) + if min_v is not None: + out["min_mem_offset_mhz"] = int(min_v) + if max_v is not None: + out["max_mem_offset_mhz"] = int(max_v) + return out + + fn = _try_nvml_fn("nvmlDeviceGetMemClkMinMaxVfOffset") + if fn is not None: + min_v = ctypes.c_int(0) + max_v = ctypes.c_int(0) + rc = fn(handle, ctypes.byref(min_v), ctypes.byref(max_v)) + if rc == 0: + out["min_mem_offset_mhz"] = int(min_v.value) + out["max_mem_offset_mhz"] = int(max_v.value) + + except Exception as exc: + log.debug("get_mem_offset_range: %s", exc) + return out + + diff --git a/nvcurve/hal/monitoring.py b/nvcurve/hal/monitoring.py new file mode 100644 index 0000000..5738943 --- /dev/null +++ b/nvcurve/hal/monitoring.py @@ -0,0 +1,124 @@ +"""Live GPU monitoring. + +Voltage is read via NvAPI GetCurrentVoltage. +Clock, temperature, power draw, and fan speed are read via NVML (nvidia-ml-py). +""" + +import struct +import time +from typing import Optional + +from ..nvapi.bootstrap import nvcall +from ..nvapi.constants import FUNC, VOLT_SIZE +from ..nvapi.types import MonitoringSample + +try: + import pynvml as _pynvml + _NVML_AVAILABLE = True +except ImportError: + _pynvml = None + _NVML_AVAILABLE = False + +_nvml_initialized = False + + +def init_nvml() -> bool: + """Initialize NVML. Call once at startup. Returns True on success.""" + global _nvml_initialized + if not _NVML_AVAILABLE: + return False + try: + _pynvml.nvmlInit() + _nvml_initialized = True + return True + except _pynvml.NVMLError: + return False + + +def shutdown_nvml() -> None: + """Shut down NVML. Call at process exit.""" + global _nvml_initialized + if _NVML_AVAILABLE and _nvml_initialized: + try: + _pynvml.nvmlShutdown() + except _pynvml.NVMLError: + pass + _nvml_initialized = False + + +def get_driver_version() -> Optional[str]: + """Return the NVIDIA driver version string, or None if unavailable.""" + if not (_NVML_AVAILABLE and _nvml_initialized): + return None + try: + return _pynvml.nvmlSystemGetDriverVersion() + except _pynvml.NVMLError: + return None + + +def get_vram_total(gpu_index: int = 0) -> Optional[int]: + """Return total VRAM in bytes, or None if unavailable.""" + if not (_NVML_AVAILABLE and _nvml_initialized): + return None + try: + handle = _pynvml.nvmlDeviceGetHandleByIndex(gpu_index) + return _pynvml.nvmlDeviceGetMemoryInfo(handle).total + except _pynvml.NVMLError: + return None + + +def read_voltage(gpu) -> tuple[Optional[int], str]: + """Read current GPU core voltage in µV via NvAPI GetCurrentVoltage. + + Returns (voltage_uV, "OK") or (None, error). + """ + d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1) + if not d: + return None, err + return struct.unpack_from(" dict: + """Read all NVML fields. Returns a dict with keys matching MonitoringSample fields.""" + out = { + "clock_mhz": None, "temp_c": None, "power_w": None, "fan_pct": None, + "pstate": None, "mem_used_bytes": None, "mem_total_bytes": None, + "gpu_util_pct": None, "mem_util_pct": None, "mem_clock_mhz": None, + } + if not (_NVML_AVAILABLE and _nvml_initialized): + return out + try: + handle = _pynvml.nvmlDeviceGetHandleByIndex(gpu_index) + + out["clock_mhz"] = float(_pynvml.nvmlDeviceGetClockInfo(handle, _pynvml.NVML_CLOCK_GRAPHICS)) + out["mem_clock_mhz"] = float(_pynvml.nvmlDeviceGetClockInfo(handle, _pynvml.NVML_CLOCK_MEM)) + out["temp_c"] = float(_pynvml.nvmlDeviceGetTemperature(handle, _pynvml.NVML_TEMPERATURE_GPU)) + out["power_w"] = _pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0 # mW → W + out["pstate"] = int(_pynvml.nvmlDeviceGetPerformanceState(handle)) + + mem = _pynvml.nvmlDeviceGetMemoryInfo(handle) + out["mem_used_bytes"] = mem.used + out["mem_total_bytes"] = mem.total + + util = _pynvml.nvmlDeviceGetUtilizationRates(handle) + out["gpu_util_pct"] = float(util.gpu) + out["mem_util_pct"] = float(util.memory) + + try: + out["fan_pct"] = float(_pynvml.nvmlDeviceGetFanSpeed(handle)) + except _pynvml.NVMLError: + pass + except _pynvml.NVMLError: + pass + return out + + +def poll(gpu, gpu_index: int = 0) -> MonitoringSample: + """Read all available monitoring data and return a MonitoringSample.""" + voltage_uv, _ = read_voltage(gpu) + nvml = _nvml_read(gpu_index) + return MonitoringSample( + timestamp=time.time(), + voltage_uv=voltage_uv, + **nvml, + ) diff --git a/nvcurve/hal/ranges.py b/nvcurve/hal/ranges.py new file mode 100644 index 0000000..83ef925 --- /dev/null +++ b/nvcurve/hal/ranges.py @@ -0,0 +1,31 @@ +"""Clock boost range queries.""" + +import struct +from typing import Optional + +from ..nvapi.bootstrap import nvcall +from ..nvapi.constants import FUNC, RANGES_SIZE + + +def get_clock_ranges(gpu) -> tuple[Optional[dict], str]: + """Read clock domain min/max offset ranges via GetClockBoostRanges. + + Returns ({"num_domains": int, "domains": [[int, ...], ...]}, "OK") + or (None, error). + + On RTX 5090: GPU core ±3000 MHz, memory -3000/+3000 MHz. + """ + d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1) + if not d: + return None, err + + num = struct.unpack_from(" len(d): + break + words = [struct.unpack_from(" Optional[str]: + """Save the current ClockBoostTable to disk. + + Writes both a binary .bin file and a human-readable .json metadata file. + If max_snapshots > 0, deletes the oldest snapshots to stay within the limit. + Returns the binary filepath on success, or None on failure. + """ + raw, err = read_clock_table_raw(gpu) + if not raw: + print(f"Failed to read ClockBoostTable: {err}") + return None + + os.makedirs(snapshot_dir, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin") + meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json") + + with open(bin_path, "wb") as f: + f.write(raw) + + offsets = [] + max_entries = (len(raw) - CT_BASE) // CT_STRIDE + for i in range(max_entries): + off = CT_BASE + i * CT_STRIDE + CT_DELTA_OFF + delta = struct.unpack_from(" 0: + _prune_snapshots(snapshot_dir, max_snapshots) + + return bin_path + + +def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None: + """Delete oldest snapshots (both .bin and .json) to stay within max_snapshots.""" + bins = sorted( + f for f in os.listdir(snapshot_dir) if f.endswith(".bin") + ) # oldest first (lexicographic = chronological for our timestamp format) + excess = len(bins) - max_snapshots + for fname in bins[:excess]: + stem = fname[:-4] # strip .bin + for ext in (".bin", ".json"): + try: + os.remove(os.path.join(snapshot_dir, stem + ext)) + except OSError: + pass + + +def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool: + """Restore a ClockBoostTable snapshot from disk. + + If no filepath is given, uses the most recent snapshot in snapshot_dir. + Returns True on success. + """ + if filepath is None: + if not os.path.isdir(snapshot_dir): + print(f"No snapshots found in {snapshot_dir}") + return False + bins = sorted( + [f for f in os.listdir(snapshot_dir) if f.endswith(".bin")], + reverse=True, + ) + if not bins: + print(f"No snapshot .bin files in {snapshot_dir}") + return False + filepath = os.path.join(snapshot_dir, bins[0]) + + if not os.path.isfile(filepath): + print(f"Snapshot file not found: {filepath}") + return False + + with open(filepath, "rb") as f: + raw = f.read() + + if len(raw) != CT_SIZE: + print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}") + return False + + vw = struct.unpack_from(" list[SnapshotInfo]: + """Return metadata for all snapshots in snapshot_dir, newest first.""" + if not os.path.isdir(snapshot_dir): + return [] + + results = [] + for fname in sorted(os.listdir(snapshot_dir), reverse=True): + if not fname.endswith(".json"): + continue + meta_path = os.path.join(snapshot_dir, fname) + try: + with open(meta_path) as f: + meta = json.load(f) + bin_path = meta.get("file", meta_path.replace(".json", ".bin")) + results.append(SnapshotInfo( + filepath=bin_path, + timestamp=meta.get("timestamp", ""), + gpu=meta.get("gpu", ""), + nonzero_offsets=meta.get("nonzero_offsets", 0), + size=meta.get("size", 0), + )) + except (json.JSONDecodeError, KeyError): + continue + + return results diff --git a/nvcurve/hal/vfcurve.py b/nvcurve/hal/vfcurve.py new file mode 100644 index 0000000..c4e7275 --- /dev/null +++ b/nvcurve/hal/vfcurve.py @@ -0,0 +1,286 @@ +"""Read and write the GPU V/F curve via NvAPI.""" + +import ctypes +import struct +from typing import Optional + +from ..nvapi.bootstrap import nvcall, nvcall_raw +from ..nvapi.constants import ( + FUNC, + VFP_SIZE, VFP_BASE, VFP_STRIDE, + CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS, +) +from ..nvapi.types import VFPoint, CurveState + + +# ── Mask helpers ───────────────────────────────────────────────────────────── + +# The boost mask is a static property of the GPU/driver — it does not change +# at runtime. Cache it per GPU handle to avoid redundant GetClockBoostMask +# calls on every HAL operation. +_boost_mask_cache: dict[int, bytes] = {} + + +def get_boost_mask(gpu) -> tuple[Optional[bytes], str]: + """Read the canonical 32-byte clock boost mask from the driver. + + The result is cached per GPU handle: subsequent calls return the cached + value without hitting the driver again. + + Returns (mask_bytes, "OK") or (None, error). + """ + if gpu in _boost_mask_cache: + return _boost_mask_cache[gpu], "OK" + + from ..nvapi.constants import MASK_SIZE + def fill(b): + for i in range(4, 4 + 32): + b[i] = 0xFF + d, err = nvcall(FUNC["GetClockBoostMask"], gpu, MASK_SIZE, ver=1, pre_fill=fill) + if d and len(d) >= 36: + mask = d[4:36] + _boost_mask_cache[gpu] = mask + return mask, "OK" + return None, err + + +def set_mask_bit(buf, point: int, offset: int = 4) -> None: + """Set a single bit in the 256-bit mask for one point.""" + byte_idx = offset + (point // 8) + bit_idx = point % 8 + buf[byte_idx] = int.from_bytes(buf[byte_idx:byte_idx + 1], "little") | (1 << bit_idx) + + +def set_mask_bits(buf, points: set[int], offset: int = 4) -> None: + """Set mask bits for a set of points.""" + for p in points: + set_mask_bit(buf, p, offset) + + +# ── Readers ─────────────────────────────────────────────────────────────────── + +def read_vfp_curve(gpu) -> tuple[Optional[list[tuple[int, int]]], str]: + """Read the base V/F curve (frequency + voltage pairs). + + Returns ([(freq_kHz, volt_uV), ...], "OK") or (None, error). + """ + mask, mask_err = get_boost_mask(gpu) + if not mask: + return None, f"GetClockBoostMask failed: {mask_err}" + + def fill(buf): + for i in range(32): + buf[4 + i] = mask[i] + + d, err = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill) + if not d: + return None, err + + points = [] + max_entries = (len(d) - VFP_BASE) // VFP_STRIDE + for i in range(max_entries): + off = VFP_BASE + i * VFP_STRIDE + freq = struct.unpack_from(" tuple[Optional[bytes], str]: + """Read the raw ClockBoostTable buffer. + + Used for snapshots, inspection, and as the baseline for writes. + Returns (bytes, "OK") or (None, error). + """ + mask, mask_err = get_boost_mask(gpu) + if not mask: + return None, f"GetClockBoostMask failed: {mask_err}" + + def fill(buf): + for i in range(32): + buf[4 + i] = mask[i] + + return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill) + + +def read_clock_table_parsed(gpu) -> tuple[Optional[list[tuple[int, int]]], str]: + """Read per-point offsets and flags from the ClockBoostTable. + + Returns a list of (delta_kHz, flags) tuples, or (None, error). + """ + d, err = read_clock_table_raw(gpu) + if not d: + return None, err + + entries = [] + max_entries = (len(d) - CT_BASE) // CT_STRIDE + for i in range(max_entries): + base_off = CT_BASE + i * CT_STRIDE + flags = struct.unpack_from(" tuple[Optional[list[int]], str]: + """Read per-point frequency offsets (kHz, signed) from the ClockBoostTable. + + Returns a list of integers, or (None, error). + """ + parsed, err = read_clock_table_parsed(gpu) + if not parsed: + return None, err + + offsets = [delta for delta, flags in parsed] + return offsets, "OK" + + +def read_clock_entry_full(data: bytes, point: int) -> dict: + """Extract all 9 raw fields from a single ClockBoostTable entry. + + Useful for diagnostics and verifying unknown fields. + """ + base = CT_BASE + point * CT_STRIDE + fields = {} + for j in range(9): + off = base + j * 4 + if j == 5: # freqDelta is signed + fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from(" tuple[Optional[CurveState], str]: + """Read both the VFP curve and ClockBoostTable and merge into CurveState. + + Returns (CurveState, "OK") or (None, error). + """ + import time + vfp_points, vfp_err = read_vfp_curve(gpu) + if not vfp_points: + return None, vfp_err + + ct_entries, ct_err = read_clock_table_parsed(gpu) + if not ct_entries: + return None, ct_err + + points = [] + in_memory = False + for i, (freq_khz, volt_uv) in enumerate(vfp_points): + if freq_khz == 0 and volt_uv == 0: + break # end of populated entries + + delta_khz = ct_entries[i][0] if i < len(ct_entries) else 0 + flags = ct_entries[i][1] if i < len(ct_entries) else 0 + + if flags == 1: + in_memory = True + + points.append(VFPoint( + index=i, + freq_khz=freq_khz, + volt_uv=volt_uv, + delta_khz=delta_khz, + domain="memory" if in_memory else "gpu", + )) + + return CurveState(points=points, timestamp=time.time(), gpu_name=gpu_name), "OK" + + +# ── Writers ─────────────────────────────────────────────────────────────────── + +def build_write_buffer( + gpu, + point_deltas: dict[int, int], + full_mask: bool = False, +) -> tuple[Optional[ctypes.Array], str]: + """Build a SetClockBoostTable buffer with specified per-point deltas. + + Strategy: read the current ClockBoostTable, modify only the targeted + entries' freqDelta fields, set only the targeted mask bits (single-bit per + write to avoid touching neighbouring points). + + Args: + full_mask: if True, copy the complete GetClockBoostMask into the write + buffer instead of the default sparse (per-point) mask. + Older GPUs (e.g. Pascal) may require this. + + Returns (mutable_buffer, "OK") or (None, error). + """ + current_raw, err = read_clock_table_raw(gpu) + if not current_raw: + return None, f"Cannot read current ClockBoostTable: {err}" + + buf = ctypes.create_string_buffer(CT_SIZE) + ctypes.memmove(buf, current_raw, CT_SIZE) + + # Rewrite version word explicitly + struct.pack_into(" tuple[int, str]: + """Write per-point frequency offsets via SetClockBoostTable. + + Args: + gpu: NvAPI GPU handle + point_deltas: {point_index: delta_kHz} — only these points are written + dry_run: if True, build the buffer but don't call the driver + full_mask: if True, use the full GetClockBoostMask (for older GPUs) + + Returns (return_code, description). + """ + buf, err = build_write_buffer(gpu, point_deltas, full_mask=full_mask) + if buf is None: + return -999, err + + if dry_run: + return 0, "DRY RUN — buffer built but not sent to driver" + + return nvcall_raw(FUNC["SetClockBoostTable"], gpu, buf) + + +def write_global_offset(gpu, delta_khz: int, dry_run: bool = False) -> tuple[int, str]: + """Apply a uniform frequency offset to all GPU core points.""" + curve, err = read_curve(gpu) + if not curve: + return -999, f"Failed to read curve: {err}" + + point_deltas = {p.index: delta_khz for p in curve.points if p.domain == "gpu"} + return write_offsets(gpu, point_deltas, dry_run=dry_run) + + +def reset_offsets(gpu, dry_run: bool = False) -> tuple[int, str]: + """Zero all GPU core frequency offsets.""" + curve, err = read_curve(gpu) + if not curve: + return -999, f"Failed to read curve: {err}" + + point_deltas = {p.index: 0 for p in curve.points if p.domain == "gpu"} + return write_offsets(gpu, point_deltas, dry_run=dry_run) diff --git a/nvcurve/nvapi/__init__.py b/nvcurve/nvapi/__init__.py new file mode 100644 index 0000000..5f17b32 --- /dev/null +++ b/nvcurve/nvapi/__init__.py @@ -0,0 +1,11 @@ +from .bootstrap import nvcall, nvcall_raw, query_interface +from .constants import FUNC +from .errors import NvAPIError, NVAPI_ERRORS +from .types import VFPoint, CurveState, MonitoringSample, GpuInfo, SnapshotInfo + +__all__ = [ + "nvcall", "nvcall_raw", "query_interface", + "FUNC", + "NvAPIError", "NVAPI_ERRORS", + "VFPoint", "CurveState", "MonitoringSample", "GpuInfo", "SnapshotInfo", +] diff --git a/nvcurve/nvapi/bootstrap.py b/nvcurve/nvapi/bootstrap.py new file mode 100644 index 0000000..f56174c --- /dev/null +++ b/nvcurve/nvapi/bootstrap.py @@ -0,0 +1,81 @@ +"""NvAPI bootstrap — library loading, function resolution, versioned struct calls.""" + +import ctypes +import struct +import sys + +from .errors import NVAPI_ERRORS + + +def load_nvapi() -> ctypes.CDLL: + """Load libnvidia-api.so from the NVIDIA driver.""" + for name in ("libnvidia-api.so", "libnvidia-api.so.1"): + try: + return ctypes.CDLL(name) + except OSError: + continue + print("Error: Cannot load libnvidia-api.so") + print("Ensure the NVIDIA proprietary driver is installed.") + sys.exit(1) + + +# Module-level library handle and QueryInterface function pointer. +_nvapi = load_nvapi() +_QI = _nvapi.nvapi_QueryInterface +_QI.restype = ctypes.c_void_p +_QI.argtypes = [ctypes.c_uint32] + + +def query_interface(fid: int, nargs: int = 2): + """Resolve an NvAPI function pointer by its 32-bit ID. + + Returns a callable ctypes function, or None if the driver doesn't expose it. + """ + ptr = _QI(fid) + if not ptr: + return None + return ctypes.CFUNCTYPE(ctypes.c_int32, *[ctypes.c_void_p] * nargs)(ptr) + + +def nvcall( + fid: int, + gpu, + size: int, + ver: int = 1, + pre_fill=None, +) -> tuple[bytes | None, str]: + """Call an NvAPI function with a versioned struct buffer. + + Allocates a buffer of `size` bytes, writes the version word + ``(ver << 16) | size`` at offset 0, optionally calls ``pre_fill(buf)`` + to populate request fields, then invokes the function. + + Returns ``(bytes, "OK")`` on success or ``(None, error_description)`` on + failure. + """ + func = query_interface(fid) + if not func: + return None, "function pointer not found (driver too old?)" + buf = ctypes.create_string_buffer(size) + struct.pack_into(" tuple[int, str]: + """Call an NvAPI function with a pre-built mutable buffer. + + Used for write operations where the caller needs full control over the + buffer contents (e.g. SetClockBoostTable). + + Returns ``(return_code, description)``. + """ + func = query_interface(fid) + if not func: + return -999, "function pointer not found (driver too old?)" + ret = func(gpu, buf) + return ret, NVAPI_ERRORS.get(ret, f"unknown ({ret})") diff --git a/nvcurve/nvapi/constants.py b/nvcurve/nvapi/constants.py new file mode 100644 index 0000000..7f00416 --- /dev/null +++ b/nvcurve/nvapi/constants.py @@ -0,0 +1,55 @@ +# NvAPI function IDs, struct sizes, offsets, and safety constants. +# All values verified by hex-dump analysis on RTX 5090 (GB202), driver 590.48.01. + +# ── Function IDs ──────────────────────────────────────────────────────────── + +FUNC = { + # Bootstrap + "Initialize": 0x0150E828, + "EnumPhysicalGPUs": 0xE5AC921F, + "GetFullName": 0xCEEE8E9F, + + # V/F curve (read) + "GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus + "GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo + "GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl + "GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus + "GetClockBoostRanges": 0x64B43A6A, # ClkDomainsGetInfo + + # Additional read + "GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus + "GetVoltBoostPercent": 0x9DF23CA1, # ClientVoltRailsGetControl + + # Write + "SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl +} + +# ── GetVFPCurve (0x21537AD4) ───────────────────────────────────────────────── +# 128 × 28-byte entries at offset 0x48; freq_kHz at +0x00, volt_uV at +0x04. + +VFP_SIZE = 0x1C28 # 7208 bytes +VFP_BASE = 0x48 +VFP_STRIDE = 0x1C # 28 bytes +VFP_POINTS = (VFP_SIZE - VFP_BASE) // VFP_STRIDE + +# ── Get/SetClockBoostTable (0x23F1B133 / 0x0733E009) ──────────────────────── +# 128 × 36-byte entries at offset 0x44; freqDelta (int32, kHz) at entry+0x14. +# Correct layout verified against LACT issue #936 (which had wrong stride 0x48). + +CT_SIZE = 0x2420 # 9248 bytes +CT_BASE = 0x44 +CT_STRIDE = 0x24 # 36 bytes +CT_DELTA_OFF = 0x14 # freqDelta offset within entry +CT_POINTS = (CT_SIZE - CT_BASE) // CT_STRIDE + +# ── Other struct sizes ─────────────────────────────────────────────────────── + +MASK_SIZE = 0x182C # GetClockBoostMask +VOLT_SIZE = 0x004C # GetCurrentVoltage (voltage µV at offset 0x28) +RANGES_SIZE = 0x0928 # GetClockBoostRanges +PERF_SIZE = 0x030C # GetPerfLimits (version 2, not 1) +VBOOST_SIZE = 0x0028 # GetVoltBoostPercent + +# ── Safety ─────────────────────────────────────────────────────────────────── + +MAX_DELTA_KHZ = 3_000_000 # ±3000 MHz hard cap (Blackwell driver limit) diff --git a/nvcurve/nvapi/errors.py b/nvcurve/nvapi/errors.py new file mode 100644 index 0000000..85128f1 --- /dev/null +++ b/nvcurve/nvapi/errors.py @@ -0,0 +1,27 @@ +# NvAPI error codes and exception class. +# Linux NvAPI uses negative integers, not the 0x80000000+ range from Windows. + +NVAPI_ERRORS: dict[int, str] = { + 0: "OK", + -1: "GENERIC_ERROR", + -5: "INVALID_ARGUMENT", + -6: "NVIDIA_DEVICE_NOT_FOUND", + -7: "END_ENUMERATION", + -8: "INVALID_HANDLE", + -9: "INCOMPATIBLE_STRUCT_VERSION", + -10: "HANDLE_INVALIDATED", + -14: "INVALID_POINTER", +} + + +class NvAPIError(Exception): + """Raised when an NvAPI call returns a non-zero error code.""" + + def __init__(self, code: int, context: str = ""): + self.code = code + self.name = NVAPI_ERRORS.get(code, f"unknown ({code})") + self.context = context + msg = f"NvAPI error {code} ({self.name})" + if context: + msg = f"{context}: {msg}" + super().__init__(msg) diff --git a/nvcurve/nvapi/types.py b/nvcurve/nvapi/types.py new file mode 100644 index 0000000..67e90e8 --- /dev/null +++ b/nvcurve/nvapi/types.py @@ -0,0 +1,76 @@ +"""Clean Python dataclasses for V/F curve data. + +Raw struct manipulation stays in the call layer (bootstrap.py + hal/). +Everything above HAL works with these types. +""" + +from dataclasses import dataclass, field + + +@dataclass +class VFPoint: + index: int + freq_khz: int # Base frequency from VFP curve + volt_uv: int # Voltage from VFP curve + delta_khz: int # Offset from ClockBoostTable (signed) + domain: str = "gpu" # "gpu" or "memory" + + @property + def effective_freq_khz(self) -> int: + return self.freq_khz + self.delta_khz + + @property + def freq_mhz(self) -> float: + return self.freq_khz / 1000.0 + + @property + def effective_freq_mhz(self) -> float: + return self.effective_freq_khz / 1000.0 + + @property + def volt_mv(self) -> float: + return self.volt_uv / 1000.0 + + @property + def delta_mhz(self) -> float: + return self.delta_khz / 1000.0 + + +@dataclass +class CurveState: + points: list[VFPoint] + timestamp: float + gpu_name: str + + +@dataclass +class MonitoringSample: + timestamp: float + voltage_uv: int | None + clock_mhz: float | None + temp_c: float | None + power_w: float | None + fan_pct: float | None + pstate: int | None # Performance state: 0 (P0, max) – 15 (P15, min) + mem_used_bytes: int | None # VRAM used (bytes) + mem_total_bytes: int | None # VRAM total (bytes) + gpu_util_pct: float | None # GPU core utilization (0–100) + mem_util_pct: float | None # Memory bus utilization (0–100) + mem_clock_mhz: float | None = None # Current memory clock (NVML_CLOCK_MEM) + + +@dataclass +class GpuInfo: + name: str + index: int + uuid: str | None = None + pci_bus_id: int | None = None + + +@dataclass +class SnapshotInfo: + filepath: str + timestamp: str + gpu: str + nonzero_offsets: int + size: int diff --git a/nvcurve/profiles/__init__.py b/nvcurve/profiles/__init__.py new file mode 100644 index 0000000..60dc80f --- /dev/null +++ b/nvcurve/profiles/__init__.py @@ -0,0 +1 @@ +# Make profiles a package diff --git a/nvcurve/profiles/apply.py b/nvcurve/profiles/apply.py new file mode 100644 index 0000000..f5c1031 --- /dev/null +++ b/nvcurve/profiles/apply.py @@ -0,0 +1,187 @@ +"""Apply saved profiles to hardware, with optional read-back verification.""" + +import json +import logging +import os +import sys +import time + +log = logging.getLogger("nvcurve.profiles.apply") + +_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json" + + +def _gpu_stable_key(info) -> str: + if info.uuid: + return info.uuid + if info.pci_bus_id is not None: + return f"pci:{info.pci_bus_id:04x}" + return f"idx:{info.index}" + + +def apply_profile(gpu_index: int, name: str, cfg) -> list[str]: + """Apply a named profile to the given GPU. Returns a list of error strings.""" + from .native import load_profile + from ..hal.gpu import get_gpu + from ..hal.limits import set_clock_offsets, set_power_limit + from ..hal.vfcurve import write_offsets, reset_offsets + from ..hal.snapshot import save as snapshot_save + from ..safety import validate_write + + safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() + filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") + + profile = load_profile(filepath) # raises FileNotFoundError if missing + gpu, gpu_name = get_gpu(index=gpu_index) + + errs: list[str] = [] + + # Apply mem offset first — driver may reset curve table as a side-effect. + if profile.mem_offset_mhz is not None: + ok, msg = set_clock_offsets(None, profile.mem_offset_mhz, gpu_index) + if not ok: + errs.append(f"Mem offset: {msg}") + + if profile.power_limit_w is not None: + ok, msg = set_power_limit(profile.power_limit_w, gpu_index) + if not ok: + errs.append(f"Power limit: {msg}") + + if profile.curve_deltas: + deltas = {int(k): v for k, v in profile.curve_deltas.items()} + errors = validate_write(deltas, cfg.max_delta_khz) + if errors: + errs.append("Curve: " + "; ".join(errors)) + else: + if cfg.auto_snapshot: + try: + snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots) + except Exception as exc: + log.warning("Auto-snapshot failed: %s", exc) + ret, desc = write_offsets(gpu, deltas) + if ret != 0: + errs.append(f"Curve write failed ({ret}): {desc}") + else: + reset_offsets(gpu) + + return errs + + +def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool: + """Apply a named profile with read-back verification, retrying on mismatch.""" + from .native import load_profile + from ..hal.gpu import get_gpu + from ..hal.vfcurve import read_clock_offsets + + safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() + filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") + + try: + profile = load_profile(filepath) + except FileNotFoundError: + log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index) + return False + + expected: dict[int, int] = ( + {int(k): v for k, v in profile.curve_deltas.items()} + if profile.curve_deltas else {} + ) + + for attempt in range(max_retries): + try: + errs = apply_profile(gpu_index, name, cfg) + except Exception as exc: + log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc) + errs = [str(exc)] + + if errs: + log.warning("Auto-load attempt %d/%d errors: %s", + attempt + 1, max_retries, "; ".join(errs)) + elif expected: + gpu, _ = get_gpu(index=gpu_index) + offsets, err = read_clock_offsets(gpu) + if offsets is None: + log.warning("Auto-load attempt %d/%d: read-back failed: %s", + attempt + 1, max_retries, err) + else: + mismatches = [ + f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz" + for idx, val in expected.items() + if idx < len(offsets) and offsets[idx] != val + ] + if not mismatches: + log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", + name, gpu_index, attempt + 1, max_retries) + return True + log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", + attempt + 1, max_retries, "; ".join(mismatches)) + else: + log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", + name, gpu_index, attempt + 1, max_retries) + return True + + if attempt < max_retries - 1: + delay = 2 ** attempt # 1s, 2s, 4s + log.info("Retrying auto-load in %ds…", delay) + time.sleep(delay) + + log.warning("Auto-load profile %r failed after %d attempts on GPU %d", + name, max_retries, gpu_index) + return False + + +def run_autoload() -> None: + """Read config and apply all configured auto-load profiles. Requires root.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + if os.geteuid() != 0: + print("nvcurve autoload: must run as root", file=sys.stderr) + sys.exit(1) + + try: + with open(_PERSISTENT_CONFIG_FILE) as f: + cfg_data = json.load(f) + except Exception: + cfg_data = {} + + auto_load_profiles: dict = cfg_data.get("auto_load_profiles", {}) + if not auto_load_profiles: + log.info("No auto-load profiles configured.") + return + + from ..config import Config + cfg = Config() + for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", + "snapshot_dir", "profile_dir"): + if key in cfg_data: + setattr(cfg, key, cfg_data[key]) + + from ..hal.gpu import init_nvapi, discover_gpus + from ..hal.monitoring import init_nvml, shutdown_nvml + + try: + init_nvapi() + except Exception as exc: + log.error("Failed to initialize NvAPI: %s", exc) + sys.exit(1) + + init_nvml() # best-effort + gpus = discover_gpus() + if not gpus: + log.warning("No GPUs discovered.") + + key_to_idx = {_gpu_stable_key(info): info.index for info in gpus} + for gpu_key, profile_name in auto_load_profiles.items(): + if not profile_name: + continue + gpu_idx = key_to_idx.get(gpu_key) + if gpu_idx is None: + log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) + continue + log.info("Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key) + apply_with_retry(gpu_idx, profile_name, cfg) + + shutdown_nvml() diff --git a/nvcurve/profiles/native.py b/nvcurve/profiles/native.py new file mode 100644 index 0000000..534f54a --- /dev/null +++ b/nvcurve/profiles/native.py @@ -0,0 +1,93 @@ +"""Native profile storage and schema.""" + +import json +import os +import glob +from dataclasses import dataclass, asdict +from typing import Dict, Optional, List + + +@dataclass +class ProfileData: + name: str + gpu_name: str + curve_deltas: Dict[str, int] # { "index": delta_khz } + mem_offset_mhz: Optional[int] = None + power_limit_w: Optional[int] = None + + +def save_profile(profile_dir: str, data: ProfileData) -> str: + """Save profile to JSON, sanitising the filename.""" + os.makedirs(profile_dir, exist_ok=True) + safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip() + if not safe_name: + safe_name = "Unnamed" + + filepath = os.path.join(profile_dir, f"{safe_name}.json") + with open(filepath, "w", encoding="utf-8") as f: + json.dump(asdict(data), f, indent=2) + return filepath + + +def load_profile(filepath: str) -> ProfileData: + """Load profile from JSON.""" + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + # Migrate old field names. + if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data: + data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz") + # Drop removed fields so old profiles don't cause TypeError. + for obsolete in ("gpu_locked_min_mhz", "gpu_locked_max_mhz", "vram_p0_offset_mhz"): + data.pop(obsolete, None) + return ProfileData(**data) + + +def list_profiles(profile_dir: str) -> List[ProfileData]: + """Return a list of all safely readable profiles.""" + if not os.path.exists(profile_dir): + return [] + profiles = [] + for fp in glob.glob(os.path.join(profile_dir, "*.json")): + try: + profiles.append(load_profile(fp)) + except Exception as e: + # log warning ideally, but swallowing for robustness + pass + # Sort alphabetically by name + profiles.sort(key=lambda p: p.name.lower()) + return profiles + + +def rename_profile(profile_dir: str, old_name: str, new_name: str) -> bool: + """Rename a profile: update the name field and move the file.""" + old_safe = "".join(c for c in old_name if c.isalnum() or c in " _-()").strip() + new_safe = "".join(c for c in new_name if c.isalnum() or c in " _-()").strip() + if not new_safe: + return False + old_path = os.path.join(profile_dir, f"{old_safe}.json") + new_path = os.path.join(profile_dir, f"{new_safe}.json") + if not os.path.exists(old_path): + return False + try: + profile = load_profile(old_path) + profile.name = new_name + with open(new_path, "w", encoding="utf-8") as f: + json.dump(asdict(profile), f, indent=2) + if old_path != new_path: + os.remove(old_path) + return True + except Exception: + return False + + +def delete_profile(profile_dir: str, name: str) -> bool: + """Delete a profile by name.""" + safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() + filepath = os.path.join(profile_dir, f"{safe_name}.json") + if os.path.exists(filepath): + try: + os.remove(filepath) + return True + except Exception: + return False + return False diff --git a/nvcurve/safety.py b/nvcurve/safety.py new file mode 100644 index 0000000..7de2731 --- /dev/null +++ b/nvcurve/safety.py @@ -0,0 +1,72 @@ +"""Centralized safety validation for all write operations. + +Every write path calls validate_write() before touching hardware. +""" + +from .nvapi.constants import CT_POINTS + + +def check_negative_freq_warnings( + point_deltas: dict[int, int], + vfp_freqs_khz: list[int], + current_offsets_khz: list[int] | None = None, +) -> list[str]: + """Return warnings for deltas that would produce negative effective frequency. + + GetVFPCurve returns the *current effective* frequencies (already reflecting + any applied boost delta). The true hardware base for each point is therefore: + true_base = vfp_freq - current_offset + + The new effective with the proposed delta: + new_effective = true_base + new_delta = vfp_freq + (new_delta - current_offset) + + The driver clamps results, but users should be informed when their delta + would push a point's effective frequency below zero. + """ + warnings = [] + for point, new_delta_khz in point_deltas.items(): + if point < 0 or point >= len(vfp_freqs_khz): + continue + vfp_freq = vfp_freqs_khz[point] + if vfp_freq == 0: + continue # unused / placeholder point + current_delta = ( + current_offsets_khz[point] + if current_offsets_khz and point < len(current_offsets_khz) + else 0 + ) + new_effective = vfp_freq + (new_delta_khz - current_delta) + if new_effective < 0: + warnings.append( + f"Point {point}: delta {new_delta_khz / 1000:+.0f} MHz would produce " + f"effective frequency {new_effective / 1000:.0f} MHz — driver will clamp to 0." + ) + return warnings + + +def validate_write( + point_deltas: dict[int, int], + max_delta_khz: int, +) -> list[str]: + """Validate a proposed write request. + + Args: + point_deltas: {point_index: delta_kHz} + max_delta_khz: absolute delta limit (e.g. 300_000 for ±300 MHz) + + Returns a list of error message strings. Empty list means the request is safe. + """ + errors = [] + for point, delta_khz in point_deltas.items(): + if point < 0 or point >= CT_POINTS: + errors.append(f"Point {point} out of range (0–{CT_POINTS - 1})") + continue + + if abs(delta_khz) > max_delta_khz: + errors.append( + f"Delta {delta_khz / 1000:+.0f} MHz for point {point} exceeds " + f"safety limit of ±{max_delta_khz / 1000:.0f} MHz. " + "Use --max-delta to raise the limit if needed." + ) + + return errors diff --git a/nvcurve/server.py b/nvcurve/server.py new file mode 100644 index 0000000..1284d79 --- /dev/null +++ b/nvcurve/server.py @@ -0,0 +1,1245 @@ +"""FastAPI API server — REST + WebSocket. + +Run via: nvcurve serve [--host 127.0.0.1 --port 8042] +Or: uvicorn nvcurve.server:app + +Requires root (NvAPI needs it). +""" + +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from .config import Config, default_config +from .hal.gpu import get_gpu, discover_gpus +from .hal.monitoring import ( + get_driver_version, + get_vram_total, + init_nvml, + poll, + shutdown_nvml, +) +from .hal.ranges import get_clock_ranges +from .hal.snapshot import ( + list_snapshots, + restore as snapshot_restore, + save as snapshot_save, +) +from .hal.limits import ( + get_power_limit, + set_power_limit, + get_clock_offsets, + set_clock_offsets, + get_mem_offset_range, +) +from .profiles.native import ( + ProfileData, + save_profile, + load_profile, + list_profiles, + delete_profile, + rename_profile, +) +from .hal.vfcurve import ( + read_clock_offsets, + read_curve, + read_vfp_curve, + reset_offsets, + write_global_offset, + write_offsets, +) +from .safety import validate_write, check_negative_freq_warnings + +log = logging.getLogger("nvcurve.server") + + +def _open_browser_as_user(url: str) -> None: + """Open URL as the original (non-root) user when running under sudo.""" + import os + import subprocess + sudo_user = os.environ.get("SUDO_USER") + if sudo_user and os.geteuid() == 0: + try: + subprocess.Popen( + ["runuser", "-u", sudo_user, "--", "xdg-open", url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except Exception: + pass + import webbrowser + webbrowser.open(url) + +# ── Shared app state ────────────────────────────────────────────────────────── + +_state: dict[str, Any] = { + "gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state + "config": default_config, +} + +def _get_gpu_state(gpu_index: int) -> dict: + if gpu_index not in _state["gpus"]: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found") + return _state["gpus"][gpu_index] + + +# ── Serialization helpers ───────────────────────────────────────────────────── + +def _vfpoint_dict(p) -> dict: + return { + "index": p.index, + "freq_khz": p.freq_khz, + "freq_mhz": p.freq_mhz, + "volt_uv": p.volt_uv, + "volt_mv": p.volt_mv, + "delta_khz": p.delta_khz, + "delta_mhz": p.delta_mhz, + "effective_freq_khz": p.effective_freq_khz, + "effective_freq_mhz": p.effective_freq_mhz, + "domain": p.domain, + } + + +def _curve_state_dict(state) -> dict: + return { + "gpu_name": state.gpu_name, + "timestamp": state.timestamp, + "points": [_vfpoint_dict(p) for p in state.points], + } + + +def _sample_dict(s) -> dict: + return { + "timestamp": s.timestamp, + "voltage_uv": s.voltage_uv, + "voltage_mv": s.voltage_uv / 1000.0 if s.voltage_uv is not None else None, + "clock_mhz": s.clock_mhz, + "mem_clock_mhz": s.mem_clock_mhz, + "temp_c": s.temp_c, + "power_w": s.power_w, + "fan_pct": s.fan_pct, + "pstate": s.pstate, + "pstate_label": f"P{s.pstate}" if s.pstate is not None else None, + "mem_used_bytes": s.mem_used_bytes, + "mem_total_bytes": s.mem_total_bytes, + "mem_used_mib": round(s.mem_used_bytes / (1024 ** 2), 1) if s.mem_used_bytes is not None else None, + "mem_total_mib": round(s.mem_total_bytes / (1024 ** 2), 1) if s.mem_total_bytes is not None else None, + "gpu_util_pct": s.gpu_util_pct, + "mem_util_pct": s.mem_util_pct, + } + + +# ── WebSocket broadcast ─────────────────────────────────────────────────────── + +async def _broadcast(clients: set, payload: dict) -> None: + """Send JSON payload to all connected WebSocket clients, evict dead ones.""" + dead = set() + for ws in list(clients): + try: + await ws.send_json(payload) + except Exception: + dead.add(ws) + clients -= dead + + +# ── Background monitoring poller ───────────────────────────────────────────── + +async def _monitor_poller(gpu_index: int) -> None: + """Continuously poll GPU state and push to connected monitor WebSocket clients.""" + cfg: Config = _state["config"] + while True: + try: + g_state = _state["gpus"].get(gpu_index) + if g_state and g_state["gpu"] is not None and g_state["monitor_clients"]: + loop = asyncio.get_running_loop() + sample = await loop.run_in_executor( + None, poll, g_state["gpu"], gpu_index + ) + await _broadcast(g_state["monitor_clients"], _sample_dict(sample)) + except Exception as exc: + log.warning("Monitor poller error for GPU %d: %s", gpu_index, exc) + await asyncio.sleep(cfg.poll_interval_s) + + +# ── Lifespan ────────────────────────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + loop = asyncio.get_running_loop() + + # Initialize NVML (best-effort) + await loop.run_in_executor(None, init_nvml) + + gpu_infos = await loop.run_in_executor(None, discover_gpus) + if not gpu_infos: + log.warning("No GPUs discovered.") + + poller_tasks = [] + + for info in gpu_infos: + idx = info.index + g_state = { + "gpu": None, + "gpu_name": info.name, + "uuid": info.uuid, + "pci_bus_id": info.pci_bus_id, + "write_lock": asyncio.Lock(), + "last_offsets": None, + "active_profile": None, + "monitor_clients": set(), + "curve_clients": set(), + } + _state["gpus"][idx] = g_state + + try: + gpu, name = await loop.run_in_executor(None, get_gpu, idx) + g_state["gpu"] = gpu + g_state["gpu_name"] = name + log.info("GPU %d: %s", idx, name) + + # Read initial offsets for reconciliation baseline + offsets, err = await loop.run_in_executor(None, read_clock_offsets, gpu) + if offsets: + g_state["last_offsets"] = offsets + + poller_tasks.append(asyncio.create_task(_monitor_poller(idx))) + except Exception as exc: + log.error("Failed to initialize GPU %d: %s", idx, exc) + + # ── Backward Compatibility Bridge ────────────────────────────────────────── + # NOTE: This auto-load path is for users running the server directly (e.g. + # via an old systemd unit file that lacks the new daemon mode). + # In the future, this will be removed and auto-loading will be the + # responsibility of daemon.py only. + cfg: Config = _state["config"] + if cfg.auto_load_profiles: + # Build a reverse map: stable_key → current gpu_index + key_to_idx = {_gpu_stable_key(idx): idx for idx in _state["gpus"]} + for gpu_key, profile_name in cfg.auto_load_profiles.items(): + if not profile_name: + continue + gpu_idx = key_to_idx.get(gpu_key) + if gpu_idx is None: + log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) + continue + log.info("Auto-loading profile %r on GPU %d (%s) [compat path]", + profile_name, gpu_idx, gpu_key) + try: + await _auto_apply_profile_with_retry(profile_name, gpu_idx) + except FileNotFoundError: + log.warning("Auto-load profile %r not found in %s — skipping GPU %d", + profile_name, cfg.profile_dir, gpu_idx) + except Exception as exc: + log.warning("Auto-load profile %r failed on GPU %d: %s — skipping", + profile_name, gpu_idx, exc) + # ────────────────────────────────────────────────────────────────────────── + + yield # server is running + + for task in poller_tasks: + task.cancel() + for task in poller_tasks: + try: + await task + except asyncio.CancelledError: + pass + + await loop.run_in_executor(None, shutdown_nvml) + + +# ── App ─────────────────────────────────────────────────────────────────────── + +app = FastAPI(title="nvcurve", version="0.5.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +# ── Request models ──────────────────────────────────────────────────────────── + +class WriteRequest(BaseModel): + deltas: dict[int, int] # {point_index: delta_kHz} + max_delta_khz: int | None = None # per-request safety limit override + + +class GlobalOffsetRequest(BaseModel): + delta_khz: int + max_delta_khz: int | None = None # per-request safety limit override + + +class VerifyRequest(BaseModel): + deltas: dict[int, int] # {point_index: delta_kHz} — pre-expanded by CLI + + +class SnapshotRestoreRequest(BaseModel): + filepath: str | None = None + + +class LimitsRequest(BaseModel): + power_limit_w: int | None = None + mem_offset_mhz: int | None = None + + +class ProfileSaveRequest(BaseModel): + name: str + + +class ProfileRenameRequest(BaseModel): + new_name: str + + +class ConfigUpdateRequest(BaseModel): + auto_load_profile: str | None = None + gpu_index: int = 0 + + +# ── Helper: run blocking HAL call in thread pool ────────────────────────────── + +async def _run(fn, *args): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, fn, *args) + + +def _require_gpu(gpu_index: int = 0): + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + if gpu is None: + raise HTTPException(status_code=503, detail=f"GPU {gpu_index} not initialized") + return gpu, g_state + + +# ── REST endpoints ──────────────────────────────────────────────────────────── + +@app.get("/api/gpus") +async def api_gpus(): + """List all discovered GPUs.""" + from .hal.gpu import discover_gpus + gpu_infos = await _run(discover_gpus) + return [ + { + "index": info.index, + "name": info.name, + "uuid": info.uuid, + "pci_bus_id": info.pci_bus_id, + } + for info in gpu_infos + ] + +@app.get("/api/gpu") +async def api_gpu(gpu_index: int = 0): + """GPU info: name, driver version, VRAM.""" + gpu, g_state = _require_gpu(gpu_index) + driver = get_driver_version() + vram = get_vram_total(gpu_index) + return { + "name": g_state["gpu_name"], + "index": gpu_index, + "driver_version": driver, + "vram_bytes": vram, + "vram_gib": round(vram / (1024 ** 3), 2) if vram else None, + } + + +@app.get("/api/curve") +async def api_curve(gpu_index: int = 0): + """Full CurveState: all V/F points with base freq, voltage, delta, effective freq.""" + gpu, g_state = _require_gpu(gpu_index) + state, err = await _run(read_curve, gpu, g_state["gpu_name"]) + if state is None: + raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}") + + # Update reconciliation baseline + g_state["last_offsets"] = [p.delta_khz for p in state.points] + return _curve_state_dict(state) + + +@app.get("/api/curve/{point}") +async def api_curve_point(point: int, gpu_index: int = 0): + """Single V/F point detail.""" + gpu, g_state = _require_gpu(gpu_index) + state, err = await _run(read_curve, gpu, g_state["gpu_name"]) + if state is None: + raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}") + if point < 0 or point >= len(state.points): + raise HTTPException(status_code=400, detail=f"Point index must be 0–{len(state.points)-1}") + return _vfpoint_dict(state.points[point]) + + +@app.get("/api/ranges") +async def api_ranges(gpu_index: int = 0): + """Clock boost domain ranges (min/max offset per domain).""" + gpu, g_state = _require_gpu(gpu_index) + ranges, err = await _run(get_clock_ranges, gpu) + if ranges is None: + raise HTTPException(status_code=500, detail=f"Failed to read ranges: {err}") + return ranges + + +@app.get("/api/voltage") +async def api_voltage(gpu_index: int = 0): + """Current GPU core voltage.""" + from .hal.monitoring import read_voltage + gpu, g_state = _require_gpu(gpu_index) + voltage_uv, err = await _run(read_voltage, gpu) + if voltage_uv is None: + raise HTTPException(status_code=500, detail=f"Failed to read voltage: {err}") + return {"voltage_uv": voltage_uv, "voltage_mv": voltage_uv / 1000.0} + + +@app.get("/api/monitor") +async def api_monitor(gpu_index: int = 0): + """One-shot monitoring snapshot: voltage, clock, temp, power, fan, p-state, VRAM, utilization.""" + gpu, g_state = _require_gpu(gpu_index) + sample = await _run(poll, gpu, gpu_index) + return _sample_dict(sample) + + +@app.get("/api/snapshots") +async def api_snapshots(): + """List saved ClockBoostTable snapshots.""" + cfg: Config = _state["config"] + snapshots = await _run(list_snapshots, cfg.snapshot_dir) + return [ + { + "filepath": s.filepath, + "timestamp": s.timestamp, + "gpu": s.gpu, + "nonzero_offsets": s.nonzero_offsets, + "size": s.size, + } + for s in snapshots + ] + + +def _persist_config_field(key: str, value) -> None: + """Write a single key into /etc/nvcurve/config.json if the file exists. + + The file is created by `service install`. If it doesn't exist (e.g. the + service was never installed), config changes are in-memory only for the + current server session. Silently ignores errors. + """ + import json as _json + import os as _os + config_path = "/etc/nvcurve/config.json" + if not _os.path.exists(config_path): + return + try: + with open(config_path) as f: + data = _json.load(f) + except Exception: + data = {} + if value is not None: + data[key] = value + else: + data.pop(key, None) + try: + with open(config_path, "w") as f: + _json.dump(data, f) + except Exception as exc: + log.warning("Failed to persist config field %r: %s", key, exc) + + +def _gpu_stable_key(gpu_index: int) -> str: + """Return a stable identifier for a GPU suitable for use as a config key. + + Preference order: NVML UUID → PCI bus ID → 'idx:{n}' fallback. + UUID is the most stable across reboots and GPU slot changes. + """ + g_state = _state["gpus"].get(gpu_index, {}) + uuid = g_state.get("uuid") + if uuid: + return uuid + pci = g_state.get("pci_bus_id") + if pci is not None: + return f"pci:{pci:04x}" + return f"idx:{gpu_index}" + + +def _persist_auto_load_profiles(profiles: dict[str, str]) -> None: + """Persist auto_load_profiles dict to config.json.""" + _persist_config_field("auto_load_profiles", profiles if profiles else None) + + +@app.get("/api/profiles") +async def api_profiles(gpu_index: int = 0): + """List saved native profiles, the active profile name, and the auto-load profile name.""" + cfg: Config = _state["config"] + profiles = await _run(list_profiles, cfg.profile_dir) + g_state = _state["gpus"].get(gpu_index) + active = g_state["active_profile"] if g_state else None + return { + "profiles": profiles, + "active": active, + "auto_load": cfg.auto_load_profiles.get(_gpu_stable_key(gpu_index)), + } + + +@app.post("/api/profiles") +async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0): + """Save current GPU state (curve deltas + limits) as a named profile.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + state, err = await _run(read_curve, gpu, g_state["gpu_name"]) + if state is None: + raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}") + + curve_deltas = {str(p.index): p.delta_khz for p in state.points if p.delta_khz != 0} + + try: + power_info = await _run(get_power_limit, gpu_index) + offsets = await _run(get_clock_offsets, gpu_index) + power_limit_w = power_info.get("power_limit_w") + mem_offset_mhz = offsets.get("mem_offset_mhz") + except Exception: + power_limit_w = None + mem_offset_mhz = None + + data = ProfileData( + name=req.name, + gpu_name=g_state["gpu_name"], + curve_deltas=curve_deltas, + mem_offset_mhz=mem_offset_mhz, + power_limit_w=power_limit_w, + ) + filepath = await _run(save_profile, cfg.profile_dir, data) + g_state["active_profile"] = req.name + return {"ok": True, "filepath": filepath} + + +async def _auto_apply_profile_with_retry( + name: str, gpu_index: int = 0, max_retries: int = 3 +) -> None: + """Apply profile on startup with read-back verification and exponential backoff retry. + + Raises FileNotFoundError if the profile does not exist (no point retrying). + Logs a warning and gives up after max_retries failed attempts. + """ + import os as _os + cfg: Config = _state["config"] + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + + safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() + filepath = _os.path.join(cfg.profile_dir, f"{safe_name}.json") + profile = await _run(load_profile, filepath) # raises FileNotFoundError if missing + + expected: dict[int, int] = ( + {int(k): v for k, v in profile.curve_deltas.items()} if profile.curve_deltas else {} + ) + + for attempt in range(max_retries): + errs = await _apply_profile(name, gpu_index) + + if errs: + log.warning("Auto-load attempt %d/%d had errors: %s", + attempt + 1, max_retries, "; ".join(errs)) + elif expected: + offsets, err = await _run(read_clock_offsets, gpu) + if offsets is None: + log.warning("Auto-load attempt %d/%d: read-back failed: %s", + attempt + 1, max_retries, err) + else: + mismatches = [ + f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz" + for idx, val in expected.items() + if idx < len(offsets) and offsets[idx] != val + ] + if not mismatches: + log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", + name, gpu_index, attempt + 1, max_retries) + return + log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", + attempt + 1, max_retries, "; ".join(mismatches)) + else: + log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", + name, gpu_index, attempt + 1, max_retries) + return + + if attempt < max_retries - 1: + delay = 2 ** attempt # 1 s, 2 s, 4 s + log.info("Retrying auto-load in %ds…", delay) + await asyncio.sleep(delay) + + log.warning("Auto-load profile %r failed after %d attempts — giving up", name, max_retries) + + +async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]: + """Load and apply a saved profile to hardware. + + Returns a list of error strings. An empty list means success. + Raises FileNotFoundError if the profile file does not exist. + Sets g_state["active_profile"] on full success. + """ + import os as _os + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + cfg: Config = _state["config"] + + safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() + filepath = _os.path.join(cfg.profile_dir, f"{safe_name}.json") + + # Let FileNotFoundError propagate so callers can map it to 404 or a warning. + profile = await _run(load_profile, filepath) + + errs: list[str] = [] + + # Apply mem offset first — driver may reset curve table as a side-effect. + if profile.mem_offset_mhz is not None: + ok, msg = await _run(set_clock_offsets, None, profile.mem_offset_mhz, gpu_index) + if not ok: + errs.append(f"Mem offset: {msg}") + + if profile.power_limit_w is not None: + ok, msg = await _run(set_power_limit, profile.power_limit_w, gpu_index) + if not ok: + errs.append(f"Power limit: {msg}") + + # Apply curve deltas (after mem offset which may have wiped them). + async with g_state["write_lock"]: + if profile.curve_deltas: + deltas = {int(k): v for k, v in profile.curve_deltas.items()} + errors = validate_write(deltas, cfg.max_delta_khz) + if errors: + errs.append("Curve: " + "; ".join(errors)) + else: + if cfg.auto_snapshot: + await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + ret, desc = await _run(write_offsets, gpu, deltas) + if ret != 0: + errs.append(f"Curve write failed ({ret}): {desc}") + else: + await _run(reset_offsets, gpu) + + await _update_offsets_and_broadcast(gpu_index) + + if not errs: + g_state["active_profile"] = name + return errs + + +@app.post("/api/profiles/{name}/apply") +async def api_profile_apply(name: str, gpu_index: int = 0): + """Apply a saved profile to hardware (curve deltas + limits).""" + _require_gpu(gpu_index) + try: + errs = await _apply_profile(name, gpu_index) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Profile '{name}' not found") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to load profile: {e}") + if errs: + raise HTTPException(status_code=500, detail="; ".join(errs)) + return {"ok": True} + + +@app.delete("/api/profiles/{name}") +async def api_profile_delete(name: str): + """Delete a saved profile by name.""" + cfg: Config = _state["config"] + ok = await _run(delete_profile, cfg.profile_dir, name) + if not ok: + raise HTTPException(status_code=404, detail=f"Profile '{name}' not found") + for g_state in _state["gpus"].values(): + if g_state["active_profile"] == name: + g_state["active_profile"] = None + changed = any(v == name for v in cfg.auto_load_profiles.values()) + if changed: + cfg.auto_load_profiles = {k: v for k, v in cfg.auto_load_profiles.items() if v != name} + _persist_auto_load_profiles(cfg.auto_load_profiles) + return {"ok": True} + + +@app.post("/api/profiles/{name}/rename") +async def api_profile_rename(name: str, req: ProfileRenameRequest): + """Rename a profile.""" + cfg: Config = _state["config"] + if not req.new_name.strip(): + raise HTTPException(status_code=400, detail="New name cannot be empty") + ok = await _run(rename_profile, cfg.profile_dir, name, req.new_name.strip()) + if not ok: + raise HTTPException(status_code=404, detail=f"Profile '{name}' not found") + for g_state in _state["gpus"].values(): + if g_state["active_profile"] == name: + g_state["active_profile"] = req.new_name.strip() + changed = any(v == name for v in cfg.auto_load_profiles.values()) + if changed: + cfg.auto_load_profiles = { + k: (req.new_name.strip() if v == name else v) + for k, v in cfg.auto_load_profiles.items() + } + _persist_auto_load_profiles(cfg.auto_load_profiles) + return {"ok": True} + + +@app.get("/api/config") +async def api_config_get(gpu_index: int = 0): + """Get mutable server configuration for a specific GPU.""" + cfg: Config = _state["config"] + return {"auto_load_profile": cfg.auto_load_profiles.get(_gpu_stable_key(gpu_index))} + + +@app.post("/api/config") +async def api_config_update(req: ConfigUpdateRequest): + """Update mutable server configuration. Changes persist to /etc/nvcurve/config.json if present.""" + if req.gpu_index not in _state["gpus"]: + raise HTTPException(status_code=404, detail=f"GPU {req.gpu_index} not found") + cfg: Config = _state["config"] + key = _gpu_stable_key(req.gpu_index) + if req.auto_load_profile: + cfg.auto_load_profiles[key] = req.auto_load_profile + else: + cfg.auto_load_profiles.pop(key, None) + _persist_auto_load_profiles(cfg.auto_load_profiles) + return {"ok": True, "auto_load_profile": cfg.auto_load_profiles.get(key)} + + +@app.get("/api/limits") +async def api_limits(gpu_index: int = 0): + """Current performance limits: power and clock offsets.""" + power = await _run(get_power_limit, gpu_index) + offsets = await _run(get_clock_offsets, gpu_index) + mem_off_range = await _run(get_mem_offset_range, gpu_index) + return { + **power, + **offsets, # gpc_offset_mhz, mem_offset_mhz + **mem_off_range, # min_mem_offset_mhz, max_mem_offset_mhz + } + + +@app.post("/api/limits") +async def api_limits_update(req: LimitsRequest, gpu_index: int = 0): + """Update performance limits.""" + g_state = _get_gpu_state(gpu_index) + errs = [] + + if req.power_limit_w is not None: + ok, msg = await _run(set_power_limit, req.power_limit_w, gpu_index) + if not ok: + errs.append(f"Power Limit: {msg}") + + if req.mem_offset_mhz is not None: + ok, msg = await _run(set_clock_offsets, None, req.mem_offset_mhz, gpu_index) + if not ok: + errs.append(f"Mem Offset: {msg}") + else: + # Setting mem offset may reset the GPC/curve table as a driver side-effect. + # Re-apply the last known curve offsets to restore them. + await _reapply_curve(gpu_index) + + if errs: + raise HTTPException(status_code=500, detail="; ".join(errs)) + + g_state["active_profile"] = None + + return {"ok": True} + + +async def _reapply_curve(gpu_index: int) -> None: + """Re-write the last known V/F curve offsets to hardware and notify WS clients.""" + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + last = g_state["last_offsets"] + if gpu is None or not last: + return + deltas = {i: off for i, off in enumerate(last) if off != 0} + if not deltas: + return + try: + await _run(write_offsets, gpu, deltas) + await _update_offsets_and_broadcast(gpu_index) + except Exception as exc: + log.warning("_reapply_curve: %s", exc) + + +async def _update_offsets_and_broadcast(gpu_index: int) -> None: + """Re-read curve offsets, update the reconciliation baseline, and push to WS clients. + + When curve WS clients are connected, a single read_curve call covers both + updating the baseline and the broadcast payload — avoiding a redundant + read_clock_offsets (ClockBoostTable) call that would otherwise happen first. + """ + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + if gpu is None: + return + if g_state["curve_clients"]: + state, _ = await _run(read_curve, gpu, g_state["gpu_name"]) + if state: + g_state["last_offsets"] = [p.delta_khz for p in state.points] + await _broadcast(g_state["curve_clients"], _curve_state_dict(state)) + else: + offsets, _ = await _run(read_clock_offsets, gpu) + g_state["last_offsets"] = offsets + + +@app.post("/api/limits/reset") +async def api_limits_reset(gpu_index: int = 0): + """Reset power limit to hardware default and memory clock offset to 0.""" + g_state = _get_gpu_state(gpu_index) + errs = [] + + power = await _run(get_power_limit, gpu_index) + default_w = power.get("default_power_limit_w") + if default_w is not None: + ok, msg = await _run(set_power_limit, default_w, gpu_index) + if not ok: + errs.append(f"Power Limit: {msg}") + + ok, msg = await _run(set_clock_offsets, None, 0, gpu_index) + if not ok: + errs.append(f"Mem Offset: {msg}") + else: + await _reapply_curve(gpu_index) + + if errs: + raise HTTPException(status_code=500, detail="; ".join(errs)) + + g_state["active_profile"] = None + + return {"ok": True} + + +# ── Write endpoints ──────────────────────────────────────────────────────────── + +async def _reconcile_check(gpu_index: int) -> dict | None: + """Re-read current offsets and return a warning dict if they differ from our last known state. + + Returns None if no external change detected (or no baseline). + """ + g_state = _get_gpu_state(gpu_index) + gpu = g_state["gpu"] + last = g_state["last_offsets"] + if last is None: + return None + + current, err = await _run(read_clock_offsets, gpu) + if current is None: + return None # Can't read — let the write attempt proceed + + changed = [i for i, (a, b) in enumerate(zip(last, current)) if a != b] + if not changed: + return None + + # External tool changed the curve — active profile is no longer current. + g_state["active_profile"] = None + + return { + "warning": "external_change_detected", + "message": ( + f"{len(changed)} point(s) changed since last read " + f"(e.g. by LACT, nvidia-smi, or another tool). " + "The write will proceed using the current hardware state." + ), + "changed_points": changed[:20], # cap list for readability + } + + +@app.post("/api/curve/write") +async def api_curve_write(req: WriteRequest, gpu_index: int = 0): + """Write per-point frequency offsets. {deltas: {point_index: delta_kHz}}""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"]) + + effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + errors = validate_write(req.deltas, effective_limit) + if errors: + raise HTTPException(status_code=400, detail={"errors": errors}) + + # Check for negative-freq warnings before writing (best-effort, non-blocking) + freq_warnings: list[str] = [] + if vfp_state: + vfp_freqs = [p.freq_khz for p in vfp_state.points] + freq_warnings = check_negative_freq_warnings( + req.deltas, vfp_freqs, g_state["last_offsets"] or [] + ) + + async with g_state["write_lock"]: + warning = await _reconcile_check(gpu_index) + + if cfg.auto_snapshot: + await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + + ret, desc = await _run(write_offsets, gpu, req.deltas) + if ret != 0: + raise HTTPException(status_code=500, detail=f"Write failed ({ret}): {desc}") + + # Update baseline and push curve update to WS clients + await _update_offsets_and_broadcast(gpu_index) + g_state["active_profile"] = None + + result = {"ok": True, "return_code": ret, "description": desc} + if warning: + result["warning"] = warning + if freq_warnings: + result["freq_warnings"] = freq_warnings + return result + + +@app.post("/api/curve/write/global") +async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0): + """Apply a uniform frequency offset to all curve points.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"]) + if not vfp_state: + raise HTTPException(status_code=500, detail="Failed to read curve") + + all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"} + effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz + errors = validate_write(all_deltas, effective_limit) + if errors: + raise HTTPException(status_code=400, detail={"errors": errors}) + + freq_warnings: list[str] = [] + if vfp_state: + vfp_freqs = [p.freq_khz for p in vfp_state.points] + freq_warnings = check_negative_freq_warnings( + all_deltas, vfp_freqs, g_state["last_offsets"] or [] + ) + + async with g_state["write_lock"]: + warning = await _reconcile_check(gpu_index) + + if cfg.auto_snapshot: + await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + + ret, desc = await _run(write_global_offset, gpu, req.delta_khz) + if ret != 0: + raise HTTPException(status_code=500, detail=f"Write failed ({ret}): {desc}") + + await _update_offsets_and_broadcast(gpu_index) + g_state["active_profile"] = None + + result = {"ok": True, "return_code": ret, "description": desc} + if warning: + result["warning"] = warning + if freq_warnings: + result["freq_warnings"] = freq_warnings + return result + + +@app.post("/api/curve/reset") +async def api_curve_reset(gpu_index: int = 0): + """Reset all frequency offsets to zero.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + async with g_state["write_lock"]: + warning = await _reconcile_check(gpu_index) + + if cfg.auto_snapshot: + await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + + ret, desc = await _run(reset_offsets, gpu) + if ret != 0: + raise HTTPException(status_code=500, detail=f"Reset failed ({ret}): {desc}") + + await _update_offsets_and_broadcast(gpu_index) + g_state["active_profile"] = None + + result = {"ok": True, "return_code": ret, "description": desc} + if warning: + result["warning"] = warning + return result + + +@app.post("/api/curve/verify") +async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0): + """Write-verify-read cycle. Returns per-point match results and collateral changes.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + errors = validate_write(req.deltas, cfg.max_delta_khz) + if errors: + raise HTTPException(status_code=400, detail={"errors": errors}) + + before_offsets, err = await _run(read_clock_offsets, gpu) + if before_offsets is None: + raise HTTPException(status_code=500, detail=f"Failed to read current state: {err}") + + # Always snapshot before verify — it's a testing operation + await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + + async with g_state["write_lock"]: + ret, desc = await _run(write_offsets, gpu, req.deltas) + if ret != 0: + raise HTTPException(status_code=500, detail=f"Write failed ({ret}): {desc}") + + await asyncio.sleep(0.2) + + after_offsets, err = await _run(read_clock_offsets, gpu) + if after_offsets is None: + raise HTTPException(status_code=500, detail=f"Verification read failed: {err}") + + g_state["active_profile"] = None + await _update_offsets_and_broadcast(gpu_index) + + points_result = [] + all_matched = True + for point, expected in sorted(req.deltas.items()): + actual = after_offsets[point] + match = actual == expected + if not match: + all_matched = False + points_result.append({ + "point": point, + "expected_khz": expected, + "actual_khz": actual, + "match": match, + }) + + collateral = [ + {"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]} + for i in range(len(before_offsets)) + if i not in req.deltas and before_offsets[i] != after_offsets[i] + ] + + return { + "ok": all_matched and not collateral, + "all_matched": all_matched, + "no_side_effects": not collateral, + "return_code": ret, + "description": desc, + "points": points_result, + "collateral_changes": collateral, + } + + +@app.post("/api/shutdown") +async def api_shutdown(): + """Gracefully shut down the server process.""" + import os + import signal + loop = asyncio.get_running_loop() + loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM)) + return {"ok": True} + + +@app.post("/api/snapshot/save") +async def api_snapshot_save(gpu_index: int = 0): + """Save a ClockBoostTable snapshot.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + path = await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots) + if path is None: + raise HTTPException(status_code=500, detail="Failed to save snapshot") + return {"ok": True, "filepath": path} + + +@app.post("/api/snapshot/restore") +async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0): + """Restore a ClockBoostTable snapshot. Uses most recent if filepath not specified.""" + gpu, g_state = _require_gpu(gpu_index) + cfg: Config = _state["config"] + + async with g_state["write_lock"]: + ok = await _run(snapshot_restore, gpu, cfg.snapshot_dir, req.filepath) + if not ok: + raise HTTPException(status_code=500, detail="Failed to restore snapshot") + + await _update_offsets_and_broadcast(gpu_index) + g_state["active_profile"] = None + + return {"ok": True} + + +# ── WebSocket endpoints ─────────────────────────────────────────────────────── + +@app.websocket("/ws/monitor") +async def ws_monitor(ws: WebSocket): + """Stream MonitoringSample at poll_interval_s. Clients receive JSON objects.""" + await ws.accept() + try: + data = await ws.receive_json() + if data.get("action") != "subscribe": + await ws.close() + return + gpu_index = data.get("gpu_index", 0) + except WebSocketDisconnect: + return + except Exception: + await ws.close() + return + + g_state = _state["gpus"].get(gpu_index) + if not g_state: + await ws.close() + return + + g_state["monitor_clients"].add(ws) + try: + gpu = g_state["gpu"] + if gpu is not None: + sample = await _run(poll, gpu, gpu_index) + await ws.send_json(_sample_dict(sample)) + + while True: + await ws.receive_text() + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + g_state["monitor_clients"].discard(ws) + + +@app.websocket("/ws/curve") +async def ws_curve(ws: WebSocket): + """Push CurveState whenever the curve changes (after writes).""" + await ws.accept() + try: + data = await ws.receive_json() + if data.get("action") != "subscribe": + await ws.close() + return + gpu_index = data.get("gpu_index", 0) + except WebSocketDisconnect: + return + except Exception: + await ws.close() + return + + g_state = _state["gpus"].get(gpu_index) + if not g_state: + await ws.close() + return + + g_state["curve_clients"].add(ws) + try: + gpu = g_state["gpu"] + if gpu is not None: + state, _ = await _run(read_curve, gpu, g_state["gpu_name"]) + if state: + await ws.send_json(_curve_state_dict(state)) + + while True: + await ws.receive_text() + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + g_state["curve_clients"].discard(ws) + + +# ── Frontend SPA ────────────────────────────────────────────────────────────── + +import os +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pathlib import Path + +# When set, suppresses the auto-open browser behaviour so the dev can open +# the Vite dev server (pnpm dev) manually instead. +_DEV_PORT = os.environ.get("NVCURVE_DEV_PORT") + +# Robust asset resolution using importlib.resources +try: + from importlib.resources import files as _resource_files + # In a packaged installation, frontend/dist is inside the package + _dist_dir = _resource_files("nvcurve") / "frontend" / "dist" + + # Fallback for local development where frontend/dist might be at project root + if not _dist_dir.is_dir(): + _here = Path(__file__).parent + _dist_dir = _here.parent / "frontend" / "dist" +except (ImportError, TypeError): + # Legacy fallback for older Python or environments without importlib.resources.files + _here = Path(__file__).parent + _dist_dir = _here / "frontend" / "dist" + if not _dist_dir.is_dir(): + _dist_dir = _here.parent / "frontend" / "dist" + +_dist_dir = str(_dist_dir) + +if os.path.isdir(os.path.join(_dist_dir, "assets")): + app.mount("/assets", StaticFiles(directory=os.path.join(_dist_dir, "assets")), name="assets") + +@app.get("/{catchall:path}") +async def serve_spa(catchall: str): + if catchall.startswith("api/") or catchall.startswith("ws/"): + raise HTTPException(status_code=404, detail="Not Found") + + if not os.path.isdir(_dist_dir): + return {"error": "Frontend not built. Run pnpm build in frontend/."} + + path = os.path.join(_dist_dir, catchall) + if os.path.isfile(path) and catchall: + return FileResponse(path) + + index = os.path.join(_dist_dir, "index.html") + if os.path.isfile(index): + return FileResponse(index) + + raise HTTPException(status_code=404, detail="Not Found") + + +# ── Factory for configured app ──────────────────────────────────────────────── + +def create_app(config: Config = default_config) -> FastAPI: + """Create a server app with a custom config (e.g. different gpu_index).""" + _state["config"] = config + return app + + +def run( + host: str = "127.0.0.1", + port: int = 8042, + gpu_index: int = 0, + config: Config = default_config, + open_browser: bool = False, +) -> None: + """Start the uvicorn server. Blocking.""" + import socket + import threading + import uvicorn + + _state["config"] = config + + # Suppress noisy websockets keepalive ping-timeout tracebacks — these are + # normal disconnection events (browser tab closed, network hiccup) and + # logging them at ERROR level creates false alarm noise. + logging.getLogger("websockets").setLevel(logging.CRITICAL) + + # Fail fast if the port is already in use — silently shifting ports breaks + # client discovery. Users should configure a different port explicitly. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind((host, port)) + except OSError: + print(f"Error: port {port} is already in use.") + print(f"Use --port N to specify a different port, or free port {port} first.") + return + + url = f"http://{host}:{port}" + + # Print banner *before* uvicorn starts so it appears above uvicorn's own output. + # GPU name is populated by the lifespan; we omit it here since the server + # hasn't started yet, and the lifespan logs it via log.info. + print("\033[1;36m" + "─" * 60 + "\033[0m") + print("\033[1;32m" + " NVCurve".center(60) + "\033[0m") + print(f" {url}".center(60)) + print("\033[1;36m" + "─" * 60 + "\033[0m") + print(" Press Ctrl+C to stop.") + print() + + if open_browser and not _DEV_PORT: + threading.Timer(1.2, lambda: _open_browser_as_user(url)).start() + + uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8566cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "nvcurve" +version = "0.5.0" +description = "Linux GPU V/F curve editor for NVIDIA GPUs" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "nvidia-ml-py>=12.0", + "pydantic>=2.0", + "httpx>=0.27", +] + +[project.scripts] +nvcurve = "nvcurve.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["nvcurve"] + +[tool.hatch.build.targets.wheel.force-include] +"frontend/dist" = "nvcurve/frontend/dist" + +[tool.hatch.build.targets.sdist] +include = [ + "/nvcurve", + "/frontend/dist", + "/README.md", +] diff --git a/scripts/nv_vfcurve_rw.py b/scripts/nv_vfcurve_rw.py new file mode 100644 index 0000000..bec42e1 --- /dev/null +++ b/scripts/nv_vfcurve_rw.py @@ -0,0 +1,1481 @@ +#!/usr/bin/env python3 +"""nv_vfcurve_rw.py — Read/Write NVIDIA GPU V/F curve via undocumented NvAPI (Linux). + +Extended from nv_vfcurve.py with WRITE support for per-point frequency offsets. + +┌─────────────────────────────────────────────────────────────────────┐ +│ WARNING: Write operations modify GPU clocking behavior. │ +│ Excessive offsets can cause instability, crashes, or artifacts. │ +│ Use small deltas (+15 to +50 MHz) initially and verify. │ +│ The --dry-run flag lets you inspect what WOULD be written. │ +└─────────────────────────────────────────────────────────────────────┘ + +Write modes: + sudo python3 nv_vfcurve_rw.py write --point 80 --delta 15 + Apply +15 MHz offset to point 80 only. + + sudo python3 nv_vfcurve_rw.py write --range 60-90 --delta 30 + Apply +30 MHz offset to points 60–90. + + sudo python3 nv_vfcurve_rw.py write --global --delta 50 + Apply +50 MHz offset to all GPU core points. + + sudo python3 nv_vfcurve_rw.py write --reset + Reset all offsets to 0. + + Add --dry-run to any write command to preview without applying. + +Read modes: + sudo python3 nv_vfcurve_rw.py read # Condensed V/F curve + sudo python3 nv_vfcurve_rw.py read --full # All points (GPU + mem) + sudo python3 nv_vfcurve_rw.py read --json # JSON output + sudo python3 nv_vfcurve_rw.py read --raw # Hex dumps + sudo python3 nv_vfcurve_rw.py read --diag # Probe all functions + +Verify mode (read-verify-read cycle): + sudo python3 nv_vfcurve_rw.py verify --point 80 --delta 15 + Write +15 MHz to point 80, then immediately re-read and confirm. + +Snapshot mode (save/restore full ClockBoostTable): + sudo python3 nv_vfcurve_rw.py snapshot save + sudo python3 nv_vfcurve_rw.py snapshot restore + +Struct layouts verified by hex-dump analysis on RTX 5090 (GB202, Blackwell), +driver 590.48.01. Cross-referenced with nvapioc (Demion), nvapi-rs (arcnmx), +and ccminer (tpruvot/cbuchner1). + +Key findings: + - Mask must come from GetClockBoostMask (all-0xFF fails on Pascal) + - Table holds up to 255 entries: GPU core + memory + - Point 127 (on RTX 5090) is the start of the memory domain (field_00=1), not idle + - Points 128+ are memory V/F entries + - freqDelta scaling is generation-dependent (÷2 on Pascal, ÷1 on Blackwell) + - NVML SetGpcClkVfOffset and SetClockBoostTable share state (last writer wins) + +See NvAPI_VF_Curve_Documentation.md for full technical details. +""" + +import ctypes +import struct +import sys +import json +import os +import time +import argparse +from datetime import datetime +from typing import Optional, List, Tuple, Set, Dict + +# ═══════════════════════════════════════════════════════════════════════════ +# NvAPI bootstrap +# ═══════════════════════════════════════════════════════════════════════════ + +def load_nvapi(): + """Load libnvidia-api.so from the NVIDIA driver.""" + for name in ("libnvidia-api.so", "libnvidia-api.so.1"): + try: + return ctypes.CDLL(name) + except OSError: + continue + print("Error: Cannot load libnvidia-api.so") + print("Ensure the NVIDIA proprietary driver is installed.") + sys.exit(1) + + +nvapi = load_nvapi() +QI = nvapi.nvapi_QueryInterface +QI.restype = ctypes.c_void_p +QI.argtypes = [ctypes.c_uint32] + +# NvAPI error codes (Linux uses negative integers, not 0x80000000+ range) +NVAPI_ERRORS = { + 0: "OK", + -1: "GENERIC_ERROR", + -5: "INVALID_ARGUMENT", + -6: "NVIDIA_DEVICE_NOT_FOUND", + -7: "END_ENUMERATION", + -8: "INVALID_HANDLE", + -9: "INCOMPATIBLE_STRUCT_VERSION", + -10: "HANDLE_INVALIDATED", + -14: "INVALID_POINTER", +} + + +def nvfunc(fid: int, nargs: int = 2): + """Resolve an NvAPI function pointer by its ID.""" + ptr = QI(fid) + if not ptr: + return None + return ctypes.CFUNCTYPE(ctypes.c_int32, *[ctypes.c_void_p] * nargs)(ptr) + + +def nvcall(fid: int, gpu, size: int, ver: int = 1, pre_fill=None): + """Call an NvAPI function with a versioned struct buffer. + + Returns (bytes, error_string) tuple. + """ + func = nvfunc(fid) + if not func: + return None, "function pointer not found (driver too old?)" + buf = ctypes.create_string_buffer(size) + struct.pack_into(" tuple: + """Initialize NvAPI, enumerate GPUs, return (handle, name).""" + init_fn = nvfunc(FUNC["Initialize"], 0) + if not init_fn or init_fn() != 0: + print("NvAPI_Initialize failed") + sys.exit(1) + + gpus = (ctypes.c_void_p * 64)() + ngpu = ctypes.c_int32() + nvfunc(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu)) + if ngpu.value == 0: + print("No NVIDIA GPUs found") + sys.exit(1) + + gpu = gpus[0] + name_buf = ctypes.create_string_buffer(256) + nvfunc(FUNC["GetFullName"])(gpu, name_buf) + return gpu, name_buf.value.decode(errors="replace") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Boost mask — the canonical source of which points are active +# +# Per nvapioc (Demion/nvapioc), the mask from GetClockBoostMask MUST be +# copied into GetVFPCurve and Get/SetClockBoostTable calls. Using all-0xFF +# works on Blackwell but fails on Pascal with GENERIC_ERROR (-1). +# +# The mask struct (0x182C bytes) contains per-entry enable/type info that +# also distinguishes GPU core vs memory clock domains. +# ═══════════════════════════════════════════════════════════════════════════ + +class BoostMask: + """Parsed GetClockBoostMask data. + + Provides the raw mask bytes for copying into other calls, plus + parsed per-entry enabled info for filtering. + """ + def __init__(self, raw: bytes): + self.raw = raw + self.size = len(raw) + + # The mask field at offset 0x04, 16 bytes — same position as in VFP/CT structs + self.mask_bytes = raw[MASK_OFFSET:MASK_OFFSET + MASK_BYTES] + + self.entries = [] + self._parse_entries() + + def _parse_entries(self): + """Parse which points have their mask bit set.""" + for i in range(min(CT_MAX_ENTRIES, MASK_BYTES * 8)): + byte_idx = i // 8 + bit_idx = i % 8 + enabled = bool(self.mask_bytes[byte_idx] & (1 << bit_idx)) + self.entries.append({"index": i, "enabled": enabled}) + + def get_enabled_indices(self) -> List[int]: + """Return list of point indices that are enabled in the mask.""" + return [e["index"] for e in self.entries if e["enabled"]] + + def count_enabled(self) -> int: + return sum(1 for e in self.entries if e["enabled"]) + + def copy_mask_into(self, buf, offset=MASK_OFFSET): + """Copy the canonical mask bytes into a target buffer.""" + for i in range(MASK_BYTES): + buf[offset + i] = self.mask_bytes[i] + + +def read_boost_mask(gpu) -> Tuple[Optional[BoostMask], str]: + """Read the clock boost mask — the canonical source of active point info. + + Per nvapioc, this mask must be copied into VFP and ClockBoostTable calls. + Using all-0xFF works on some GPUs (Blackwell) but fails on others (Pascal). + """ + def fill(buf): + for i in range(MASK_OFFSET, MASK_OFFSET + MASK_BYTES): + buf[i] = 0xFF + + d, err = nvcall(FUNC["GetClockBoostMask"], gpu, MASK_SIZE, ver=1, pre_fill=fill) + if not d: + return None, err + + return BoostMask(d), "OK" + + +# ═══════════════════════════════════════════════════════════════════════════ +# Point classification — GPU core vs memory +# ═══════════════════════════════════════════════════════════════════════════ + +class CurveInfo: + """Holds classified point information for the GPU's V/F curve. + + Combines data from GetClockBoostMask, GetVFPCurve, and GetClockBoostTable + to determine which points are GPU core and which are memory. + """ + def __init__(self): + self.gpu_points: List[int] = [] # GPU core V/F point indices + self.mem_points: List[int] = [] # Memory V/F point indices + self.total_points: int = 0 # Total populated entries + self.mask: Optional[BoostMask] = None + + @staticmethod + def build(gpu, mask: Optional[BoostMask] = None) -> 'CurveInfo': + """Classify all points by reading CT field_00 and VFP data. + + field_00 == 0: GPU core data point + field_00 == 1: start of memory domain (this point and subsequent are memory points) + """ + info = CurveInfo() + info.mask = mask + + # Read ClockBoostTable to check field_00 + ct_raw = _read_clock_table_raw_with_mask(gpu, mask) + if not ct_raw: + # Fallback: assume 128 GPU points + info.gpu_points = list(range(128)) + info.total_points = 128 + return info + + # Read VFP curve for frequency data + vfp_points = _read_vfp_with_mask(gpu, mask) + + # Scan all possible entries + in_memory_domain = False + for i in range(CT_MAX_ENTRIES): + off = CT_BASE + i * CT_STRIDE + if off + CT_STRIDE > len(ct_raw): + break + + field_00 = struct.unpack_from(" 0 or v > 0) + + has_ct_data = False + for j in range(9): + val = struct.unpack_from(" str: + parts = [f"{len(self.gpu_points)} GPU core points"] + if self.mem_points: + parts.append(f"{len(self.mem_points)} memory points") + parts.append(f"{self.total_points} total") + return ", ".join(parts) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Data readers (mask-aware) +# ═══════════════════════════════════════════════════════════════════════════ + +def _fill_mask_from_boost(buf, mask: BoostMask): + """Copy boost mask into buffer.""" + mask.copy_mask_into(buf) + + +def _read_vfp_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[List[Tuple[int, int]]]: + """Read VFP curve using the canonical boost mask.""" + def fill(buf): + _fill_mask_from_boost(buf, mask) + + d, err = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill) + if not d: + return None + + points = [] + for i in range(VFP_MAX_ENTRIES): + off = VFP_BASE + i * VFP_STRIDE + if off + 8 > len(d): + break + freq = struct.unpack_from(" Optional[bytes]: + """Read raw ClockBoostTable using the canonical boost mask.""" + def fill(buf): + _fill_mask_from_boost(buf, mask) + + d, err = nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill) + return d if d else None + + +def read_vfp_curve(gpu, mask: Optional[BoostMask] = None, + curve_info: Optional[CurveInfo] = None + ) -> Tuple[Optional[List[Tuple[int, int]]], str]: + """Read V/F curve (frequency + voltage pairs). + + Returns up to 255 entries. Use curve_info to determine which are GPU/mem. + """ + def fill(buf): + _fill_mask_from_boost(buf, mask) + + d, err = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill) + if not d: + return None, err + + max_entries = VFP_MAX_ENTRIES + if curve_info and curve_info.total_points > 0: + max_entries = curve_info.total_points + + points = [] + for i in range(max_entries): + off = VFP_BASE + i * VFP_STRIDE + if off + 8 > len(d): + break + freq = struct.unpack_from(" Tuple[Optional[bytes], str]: + """Read the raw ClockBoostTable buffer.""" + def fill(buf): + _fill_mask_from_boost(buf, mask) + + return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill) + + +def read_clock_offsets(gpu, mask: Optional[BoostMask] = None, + curve_info: Optional[CurveInfo] = None + ) -> Tuple[Optional[List[int]], str]: + """Read per-point frequency offsets from the ClockBoostTable.""" + d, err = read_clock_table_raw(gpu, mask) + if not d: + return None, err + + max_entries = CT_MAX_ENTRIES + if curve_info and curve_info.total_points > 0: + max_entries = curve_info.total_points + + offsets = [] + actual_max = min(max_entries, (len(d) - CT_BASE) // CT_STRIDE) + for i in range(actual_max): + off = CT_BASE + i * CT_STRIDE + CT_DELTA_OFF + delta = struct.unpack_from(" dict: + """Extract all 9 fields from a single ClockBoostTable entry.""" + base = CT_BASE + point * CT_STRIDE + if base + CT_STRIDE > len(data): + return {} + fields = {} + for j in range(9): + off = base + j * 4 + if j == 5: + fields[f"field_{j:02d}_0x{j*4:02X}"] = struct.unpack_from(" Tuple[Optional[int], str]: + """Read current GPU core voltage in µV.""" + d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1) + if not d: + return None, err + return struct.unpack_from(" Tuple[Optional[dict], str]: + """Read clock domain min/max offset ranges.""" + d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1) + if not d: + return None, err + num = struct.unpack_from(" len(d): + break + words = [struct.unpack_from(" Tuple[Optional[ctypes.Array], str]: + """Build a SetClockBoostTable buffer with specified per-point deltas. + + Strategy: read the current ClockBoostTable (using canonical mask), + modify only the targeted entries' freqDelta fields, set only the + targeted mask bits for the write call. + + Returns (mutable_buffer, error_string). + """ + current_raw, err = read_clock_table_raw(gpu, mask) + if not current_raw: + return None, f"Cannot read current ClockBoostTable: {err}" + + buf = ctypes.create_string_buffer(CT_SIZE) + ctypes.memmove(buf, current_raw, CT_SIZE) + + # Rewrite version word + struct.pack_into(" Tuple[int, str]: + """Write per-point frequency offsets via SetClockBoostTable.""" + buf, err = build_write_buffer(gpu, point_deltas, mask) + if buf is None: + return -999, err + + if dry_run: + return 0, "DRY RUN — buffer built but not sent to driver" + + ret, desc = nvcall_raw(FUNC["SetClockBoostTable"], gpu, buf) + return ret, desc + + +# ═══════════════════════════════════════════════════════════════════════════ +# Safety checks +# ═══════════════════════════════════════════════════════════════════════════ + +def validate_write_request(point_deltas: dict, + curve_info: Optional[CurveInfo] = None + ) -> Optional[str]: + """Return an error message if the write request is unsafe, else None.""" + mem_points = set() + if curve_info: + mem_points = set(curve_info.mem_points) + + for point, delta_khz in point_deltas.items(): + if point < 0 or point >= CT_MAX_ENTRIES: + return f"Point {point} out of range (0–{CT_MAX_ENTRIES - 1})" + + if point in mem_points: + return (f"Point {point} is a memory clock entry. " + "Memory offsets use a different mechanism (NVML). " + "Use --force if you really mean it.") + + if abs(delta_khz) > MAX_DELTA_KHZ: + return (f"Delta {delta_khz/1000:+.0f} MHz for point {point} exceeds " + f"safety limit of ±{MAX_DELTA_KHZ/1000:.0f} MHz. " + "Use --max-delta to raise the limit if needed.") + + return None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Hex dump utility +# ═══════════════════════════════════════════════════════════════════════════ + +def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str: + lines = [] + end = min(start + length, len(data)) + for off in range(start, end, cols): + chunk = data[off:off + cols] + hx = " ".join(f"{b:02x}" for b in chunk) + asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}") + return "\n".join(lines) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Snapshot save/restore +# ═══════════════════════════════════════════════════════════════════════════ + +def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None): + """Save the current ClockBoostTable to disk.""" + raw, err = read_clock_table_raw(gpu, mask) + if not raw: + print(f"Failed to read ClockBoostTable: {err}") + return False + + os.makedirs(SNAPSHOT_DIR, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + fname = os.path.join(SNAPSHOT_DIR, f"clock_boost_table_{ts}.bin") + meta_fname = os.path.join(SNAPSHOT_DIR, f"clock_boost_table_{ts}.json") + + with open(fname, "wb") as f: + f.write(raw) + + # Save human-readable metadata — scan all possible entries + offsets = [] + for i in range(CT_MAX_ENTRIES): + off = CT_BASE + i * CT_STRIDE + CT_DELTA_OFF + if off + 4 > len(raw): + break + delta = struct.unpack_from(" 0 and abs(v - voltage) < 10000: + current_idx = i + break + + gpu_set = set(curve_info.gpu_points) if curve_info else set() + mem_set = set(curve_info.mem_points) if curve_info else set() + + if full: + show = list(range(len(points))) + else: + show = [] + prev_freq = -1 + for i, (f, v) in enumerate(points): + if f == 0 and v == 0: + continue + if i in mem_set: + show.append(i) + elif f != prev_freq or i == len(points) - 1: + show.append(i) + prev_freq = f + + print(f"{'#':>3s} {'Freq':>8s} {'Voltage':>8s} {'Offset':>8s} {'Domain'}") + print("-" * 56) + + for i in show: + if i >= len(points): + break + f, v = points[i] + if f == 0 and v == 0: + continue + + freq_s = f"{f / 1000:.0f} MHz" + volt_s = f"{v / 1000:.0f} mV" + + offset_s = "" + if offsets and i < len(offsets) and offsets[i] != 0: + offset_s = f"{offsets[i] / 1000:+.0f} MHz" + + domain = "" + if i in mem_set: + domain = "memory" + elif i in gpu_set: + domain = "gpu" + + marker = "" + if current_idx is not None and i == current_idx: + marker = " <-- current" + + print(f"{i:3d} {freq_s:>8s} {volt_s:>8s} {offset_s:>8s} {domain}{marker}") + + # Summary + if curve_info and curve_info.gpu_points: + gpu_data = [(points[i][0], points[i][1]) for i in curve_info.gpu_points + if i < len(points) and points[i][0] > 0] + if gpu_data: + freqs = [f for f, v in gpu_data] + volts = [v for f, v in gpu_data] + print() + print(f"GPU core: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " + f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " + f"({len(gpu_data)} points)") + + if curve_info and curve_info.mem_points: + mem_data = [(points[i][0], points[i][1]) for i in curve_info.mem_points + if i < len(points) and points[i][0] > 0] + if mem_data: + freqs = [f for f, v in mem_data] + volts = [v for f, v in mem_data] + print(f"Memory: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, " + f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV " + f"({len(mem_data)} points)") + + if offsets: + gpu_indices = set(curve_info.gpu_points) if curve_info else set(range(len(offsets))) + gpu_offsets = [offsets[i] for i in gpu_indices + if i < len(offsets) and offsets[i] != 0] + if gpu_offsets: + vals = set(gpu_offsets) + if len(vals) == 1: + print(f"GPU offset: {next(iter(vals))/1000:+.0f} MHz " + f"(uniform across {len(gpu_offsets)} points)") + else: + print(f"GPU offsets: {len(gpu_offsets)} points active " + f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)") + + +def output_json(gpu_name, points, offsets, voltage, + curve_info: Optional[CurveInfo] = None): + """Output JSON format.""" + data = { + "gpu": gpu_name, + "current_voltage_uV": voltage, + "layout": { + "vfp_curve": {"size": VFP_SIZE, "base": VFP_BASE, + "stride": VFP_STRIDE, "max_entries": VFP_MAX_ENTRIES}, + "clock_table": {"size": CT_SIZE, "base": CT_BASE, + "stride": CT_STRIDE, "delta_offset": CT_DELTA_OFF, + "max_entries": CT_MAX_ENTRIES}, + }, + "curve_info": { + "gpu_points": curve_info.gpu_points if curve_info else [], + "mem_points": curve_info.mem_points if curve_info else [], + "total_points": curve_info.total_points if curve_info else len(points), + }, + "vf_curve": [], + } + + gpu_set = set(curve_info.gpu_points) if curve_info else set() + mem_set = set(curve_info.mem_points) if curve_info else set() + + for i, (f, v) in enumerate(points): + if f > 0 or v > 0: + entry = {"index": i, "freq_kHz": f, "volt_uV": v} + if offsets and i < len(offsets): + entry["freq_offset_kHz"] = offsets[i] + if i in mem_set: + entry["domain"] = "memory" + elif i in gpu_set: + entry["domain"] = "gpu" + data["vf_curve"].append(entry) + + print(json.dumps(data, indent=2)) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Write command handler +# ═══════════════════════════════════════════════════════════════════════════ + +def cmd_write(gpu, gpu_name, args, mask, curve_info): + """Handle write subcommand.""" + delta_khz = int(args.delta * 1000) + point_deltas = {} + + # Determine GPU-only points for --global and --reset + if curve_info and curve_info.gpu_points: + gpu_points = curve_info.gpu_points + else: + gpu_points = list(range(127)) + + if args.reset: + delta_khz = 0 + for i in gpu_points: + point_deltas[i] = 0 + print(f"Resetting offsets to 0 on {len(point_deltas)} GPU core points") + + elif args.point is not None: + point_deltas[args.point] = delta_khz + print(f"Target: point {args.point}, delta {args.delta:+.0f} MHz " + f"({delta_khz:+d} kHz)") + + elif args.range: + start, end = args.range + for i in range(start, end + 1): + point_deltas[i] = delta_khz + print(f"Target: points {start}–{end} ({len(point_deltas)} points), " + f"delta {args.delta:+.0f} MHz") + + elif args.glob: + for i in gpu_points: + point_deltas[i] = delta_khz + print(f"Target: all {len(point_deltas)} GPU core points, " + f"delta {args.delta:+.0f} MHz") + + else: + print("Error: specify --point N, --range A-B, --global, or --reset") + return + + # Safety check + if not args.force: + err = validate_write_request(point_deltas, curve_info) + if err: + print(f"\nSafety check FAILED: {err}") + return + + # Read current state for comparison + current_offsets, _ = read_clock_offsets(gpu, mask, curve_info) + + # Show what will change + print() + print("Changes to apply:") + changed = 0 + for point in sorted(point_deltas.keys()): + new = point_deltas[point] + old = current_offsets[point] if current_offsets and point < len(current_offsets) else 0 + if old != new: + changed += 1 + if changed <= 20: + print(f" Point {point:3d}: {old/1000:+8.0f} MHz → {new/1000:+8.0f} MHz") + if changed > 20: + print(f" ... and {changed - 20} more points") + if changed == 0: + print(" (no changes — offsets already match)") + return + + if args.dry_run: + print() + print("DRY RUN — no changes applied.") + + buf, err = build_write_buffer(gpu, point_deltas, mask) + if buf: + print() + print("Buffer header (first 0x44 bytes):") + print(hexdump(bytes(buf), 0x00, 0x44)) + + first_pt = min(point_deltas.keys()) + entry_off = CT_BASE + first_pt * CT_STRIDE + print(f"\nEntry for point {first_pt} (offset 0x{entry_off:04X}, " + f"stride 0x{CT_STRIDE:02X}):") + print(hexdump(bytes(buf), entry_off, CT_STRIDE)) + return + + # Auto-save snapshot before write + print() + print("Saving pre-write snapshot...") + snapshot_save(gpu, gpu_name, mask) + + # Execute write + print() + print("Writing to GPU...") + ret, desc = write_clock_offsets(gpu, point_deltas, mask) + print(f"SetClockBoostTable returned: {ret} ({desc})") + + if ret != 0: + print("\nWrite FAILED. GPU state unchanged.") + return + + # Verify by re-reading + print() + print("Verifying write...") + time.sleep(0.1) + new_offsets, err = read_clock_offsets(gpu, mask, curve_info) + if not new_offsets: + print(f"WARNING: Verification read failed: {err}") + return + + mismatches = 0 + for point, expected in point_deltas.items(): + actual = new_offsets[point] if point < len(new_offsets) else 0 + if actual != expected: + mismatches += 1 + print(f" MISMATCH point {point}: expected {expected/1000:+.0f} MHz, " + f"got {actual/1000:+.0f} MHz") + + if mismatches == 0: + print(f"Verified: all {len(point_deltas)} points match expected values.") + else: + print(f"\nWARNING: {mismatches} point(s) did not match!") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Verify command handler +# ═══════════════════════════════════════════════════════════════════════════ + +def cmd_verify(gpu, gpu_name, args, mask, curve_info): + """Write-verify-read cycle for a single point or range.""" + delta_khz = int(args.delta * 1000) + + if args.point is not None: + points = [args.point] + elif args.range: + points = list(range(args.range[0], args.range[1] + 1)) + else: + print("Error: --point or --range required for verify mode") + return + + point_deltas = {p: delta_khz for p in points} + + err = validate_write_request(point_deltas, curve_info) + if err: + print(f"Safety check FAILED: {err}") + return + + print(f"=== Write-Verify Cycle ===") + print(f"GPU: {gpu_name}") + if curve_info: + print(f"Curve: {curve_info.describe()}") + print(f"Points: {points[0]}{'–' + str(points[-1]) if len(points) > 1 else ''}") + print(f"Delta: {args.delta:+.0f} MHz ({delta_khz:+d} kHz)") + print() + + # Step 1: Read BEFORE state + print("Step 1: Reading current state...") + before_offsets, err = read_clock_offsets(gpu, mask, curve_info) + if not before_offsets: + print(f" FAILED: {err}") + return + before_raw, _ = read_clock_table_raw(gpu, mask) + + for p in points[:5]: + entry = read_clock_entry_full(before_raw, p) if before_raw else {} + off_val = before_offsets[p] if p < len(before_offsets) else 0 + print(f" Point {p:3d}: freqDelta = {off_val/1000:+8.0f} MHz") + if entry: + print(f" All fields: {entry}") + + # Step 2: Save snapshot + print() + print("Step 2: Saving pre-write snapshot...") + snapshot_save(gpu, gpu_name, mask) + + # Step 3: Write + print() + print("Step 3: Writing offsets...") + ret, desc = write_clock_offsets(gpu, point_deltas, mask) + print(f" SetClockBoostTable returned: {ret} ({desc})") + if ret != 0: + print(" FAILED — aborting verify cycle.") + return + + # Step 4: Read AFTER state + print() + print("Step 4: Reading back (verification)...") + time.sleep(0.2) + after_offsets, err = read_clock_offsets(gpu, mask, curve_info) + if not after_offsets: + print(f" FAILED: {err}") + return + after_raw, _ = read_clock_table_raw(gpu, mask) + + all_ok = True + for p in points: + expected = delta_khz + actual = after_offsets[p] if p < len(after_offsets) else 0 + match = "OK" if actual == expected else "MISMATCH" + if actual != expected: + all_ok = False + print(f" Point {p:3d}: expected {expected/1000:+8.0f} MHz, " + f"got {actual/1000:+8.0f} MHz [{match}]") + + # Step 5: Check for collateral damage + print() + print("Step 5: Checking for unintended side effects...") + collateral = 0 + check_range = min(len(before_offsets), len(after_offsets)) + for i in range(check_range): + if i in point_deltas: + continue + if before_offsets[i] != after_offsets[i]: + collateral += 1 + print(f" WARNING: Point {i} changed unexpectedly: " + f"{before_offsets[i]/1000:+.0f} → {after_offsets[i]/1000:+.0f} MHz") + if collateral == 0: + print(" No unintended changes detected.") + + # Step 6: Check if unknown fields changed + if before_raw and after_raw: + print() + print("Step 6: Checking if driver modified any unknown fields...") + field_changes = 0 + for p in points[:5]: + before_entry = read_clock_entry_full(before_raw, p) + after_entry = read_clock_entry_full(after_raw, p) + for key in before_entry: + if key == "freqDelta_kHz": + continue + if before_entry[key] != after_entry[key]: + field_changes += 1 + print(f" Point {p}, {key}: {before_entry[key]} → {after_entry[key]}") + if field_changes == 0: + print(" No unknown fields changed.") + + # Step 7: Read voltage + voltage, _ = read_voltage(gpu) + if voltage: + print(f"\nCurrent voltage after write: {voltage/1000:.1f} mV") + + # Summary + print() + print("=" * 50) + if all_ok and collateral == 0: + print("RESULT: Write verified successfully.") + elif not all_ok: + print("RESULT: Write verification FAILED — offsets don't match.") + else: + print("RESULT: Write applied but with unexpected side effects.") + + print() + print("To undo this change, run:") + print(f" sudo python3 {sys.argv[0]} snapshot restore") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Inspect command +# ═══════════════════════════════════════════════════════════════════════════ + +def cmd_inspect(gpu, gpu_name, args, mask, curve_info): + """Show detailed field-level data for specific points.""" + raw, err = read_clock_table_raw(gpu, mask) + if not raw: + print(f"Failed to read ClockBoostTable: {err}") + return + + vfp_points, _ = read_vfp_curve(gpu, mask, curve_info) + + max_point = curve_info.total_points if curve_info else CT_MAX_ENTRIES + + if args.point is not None: + indices = [args.point] + elif args.range: + indices = list(range(args.range[0], args.range[1] + 1)) + else: + # Default: show interesting points + defaults = [0, 1, 50, 51, 80, 126] + if curve_info: + if curve_info.mem_points: + mp = curve_info.mem_points[0] + defaults.extend([mp - 1, mp, mp + 1]) + defaults.append(curve_info.mem_points[-1]) + indices = sorted(set(defaults)) + + gpu_set = set(curve_info.gpu_points) if curve_info else set() + mem_set = set(curve_info.mem_points) if curve_info else set() + + print(f"GPU: {gpu_name}") + if curve_info: + print(f"Curve: {curve_info.describe()}") + print(f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, " + f"9 fields × 4 bytes)") + print() + + for p in indices: + if p < 0 or p >= max_point: + continue + entry = read_clock_entry_full(raw, p) + if not entry: + continue + off = CT_BASE + p * CT_STRIDE + + domain = "" + if p in mem_set: + domain = " [MEMORY]" + elif p in gpu_set: + domain = " [GPU]" + + freq_str = "" + if vfp_points and p < len(vfp_points): + f, v = vfp_points[p] + freq_str = f" (VFP: {f/1000:.0f} MHz @ {v/1000:.0f} mV)" + + print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain}{freq_str}") + for key, val in entry.items(): + if key == "freqDelta_kHz": + continue + marker = " ← freqDelta" if "0x14" in key else "" + if "0x14" in key: + print(f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})" + f" = {val/1000:+.0f} MHz{marker}") + else: + print(f" {key}: {val:12d} (0x{val:08X})") + print() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Read command handler +# ═══════════════════════════════════════════════════════════════════════════ + +def cmd_read(gpu, gpu_name, args, mask, curve_info): + """Handle read subcommand.""" + if args.diag: + run_diagnostics(gpu, gpu_name, mask) + return + + points, vfp_err = read_vfp_curve(gpu, mask, curve_info) + offsets, ct_err = read_clock_offsets(gpu, mask, curve_info) + voltage, _ = read_voltage(gpu) + + if not points: + print(f"GPU: {gpu_name}") + print(f"Failed to read V/F curve: {vfp_err}") + print("Run with 'read --diag' to probe all functions.") + return + + if args.json: + output_json(gpu_name, points, offsets, voltage, curve_info) + return + + print(f"GPU: {gpu_name}") + + if args.raw: + def fill_vfp(buf): + _fill_mask_from_boost(buf, mask) + vfp_raw, _ = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, + ver=1, pre_fill=fill_vfp) + ct_raw, _ = read_clock_table_raw(gpu, mask) + + if vfp_raw: + print() + print("=== VFP Curve (0x21537AD4) — header + first entries ===") + print(hexdump(vfp_raw, 0x00, 0x48)) + print(" --- data at 0x48, stride 0x1C ---") + print(hexdump(vfp_raw, 0x48, VFP_STRIDE * 5)) + + if ct_raw: + print() + print("=== ClockBoostTable (0x23F1B133) — header + first entries ===") + print(hexdump(ct_raw, 0x00, 0x44)) + print(" --- data at 0x44, stride 0x24, freqDelta at +0x14 ---") + print(hexdump(ct_raw, 0x44, CT_STRIDE * 5)) + + # Show around the memory domain if known + if curve_info and curve_info.mem_points: + mp = curve_info.mem_points[0] + if ct_raw: + mp_off = CT_BASE + (mp - 1) * CT_STRIDE + print(f"\n --- around memory transition (point {mp}) ---") + print(hexdump(ct_raw, mp_off, CT_STRIDE * 4)) + print() + + if not offsets: + print(f"(Clock offsets unavailable: {ct_err})") + + print_curve(points, offsets, voltage, curve_info, full=args.full) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Argument parsing +# ═══════════════════════════════════════════════════════════════════════════ + +def parse_range(s: str) -> Tuple[int, int]: + """Parse 'A-B' into (A, B) tuple.""" + parts = s.split("-") + if len(parts) != 2: + raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'") + try: + a, b = int(parts[0]), int(parts[1]) + except ValueError: + raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") + if a > b: + raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}") + if a < 0 or b >= CT_MAX_ENTRIES: + raise argparse.ArgumentTypeError(f"Range {a}-{b} outside 0–{CT_MAX_ENTRIES - 1}") + return (a, b) + + +def main(): + parser = argparse.ArgumentParser( + description="Read/Write NVIDIA GPU V/F curve via undocumented NvAPI (Linux)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s read Condensed V/F curve (GPU + memory) + %(prog)s read --full All points including empty slots + %(prog)s read --json JSON output with domain classification + %(prog)s read --diag Probe all functions + mask comparison + %(prog)s inspect Show boundary and interesting points + %(prog)s inspect --point 80 Show all 9 fields for point 80 + %(prog)s inspect --range 125-132 Show GPU/boundary/memory transition + %(prog)s write --point 80 --delta 15 --dry-run + Preview writing +15 MHz to point 80 + %(prog)s verify --point 80 --delta 15 Write + verify cycle + %(prog)s write --global --delta 50 +50 MHz to all GPU core points + %(prog)s write --reset Reset all GPU core offsets to 0 + %(prog)s snapshot save Save current ClockBoostTable + %(prog)s snapshot restore Restore most recent snapshot +""", + ) + sub = parser.add_subparsers(dest="command") + + # --- read --- + p_read = sub.add_parser("read", help="Read V/F curve (default)") + p_read.add_argument("--full", action="store_true", + help="Show all points including empty slots") + p_read.add_argument("--json", action="store_true", + help="JSON output with domain classification") + p_read.add_argument("--raw", action="store_true", + help="Include hex dumps") + p_read.add_argument("--diag", action="store_true", + help="Probe all functions with mask comparison") + + # --- inspect --- + p_insp = sub.add_parser("inspect", help="Show detailed entry fields") + p_insp.add_argument("--point", type=int, help="Single point index") + p_insp.add_argument("--range", type=parse_range, help="Point range A-B") + + # --- write --- + p_write = sub.add_parser("write", help="Write frequency offsets") + tgt = p_write.add_mutually_exclusive_group() + tgt.add_argument("--point", type=int, help="Single point index") + tgt.add_argument("--range", type=parse_range, help="Point range A-B") + tgt.add_argument("--global", dest="glob", action="store_true", + help="All GPU core points") + tgt.add_argument("--reset", action="store_true", + help="Reset all GPU core offsets to 0") + p_write.add_argument("--delta", type=float, default=0.0, + help="Frequency offset in MHz (e.g. 15, -30)") + p_write.add_argument("--dry-run", action="store_true", + help="Preview changes without applying") + p_write.add_argument("--force", action="store_true", + help="Allow modifying memory points") + p_write.add_argument("--max-delta", type=float, default=300.0, + help="Override safety limit (MHz, default 300)") + + # --- verify --- + p_ver = sub.add_parser("verify", help="Write-verify-read cycle") + p_ver.add_argument("--point", type=int, help="Single point index") + p_ver.add_argument("--range", type=parse_range, help="Point range A-B") + p_ver.add_argument("--delta", type=float, required=True, + help="Frequency offset in MHz") + + # --- snapshot --- + p_snap = sub.add_parser("snapshot", help="Save/restore ClockBoostTable") + p_snap.add_argument("action", choices=["save", "restore"], + help="save or restore") + p_snap.add_argument("--file", help="Snapshot file path (for restore)") + + args = parser.parse_args() + + # Default to 'read' if no subcommand + if args.command is None: + args.command = "read" + args.full = False + args.json = False + args.raw = False + args.diag = False + + # Update safety limit if overridden + global MAX_DELTA_KHZ + if args.command == "write" and hasattr(args, "max_delta"): + MAX_DELTA_KHZ = int(args.max_delta * 1000) + + gpu, gpu_name = init_gpu() + + # Read boost mask first — this is the canonical source per nvapioc + mask, mask_err = read_boost_mask(gpu) + if not mask: + print(f"Error: Could not read boost mask ({mask_err}).") + print("This mask is required to safely read/write the V/F curve.") + sys.exit(1) + + # Classify points (GPU core vs memory) + curve_info = CurveInfo.build(gpu, mask) + + if args.command == "read": + cmd_read(gpu, gpu_name, args, mask, curve_info) + elif args.command == "inspect": + cmd_inspect(gpu, gpu_name, args, mask, curve_info) + elif args.command == "write": + cmd_write(gpu, gpu_name, args, mask, curve_info) + elif args.command == "verify": + cmd_verify(gpu, gpu_name, args, mask, curve_info) + elif args.command == "snapshot": + if args.action == "save": + snapshot_save(gpu, gpu_name, mask) + elif args.action == "restore": + snapshot_restore(gpu, mask, args.file) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..eec27f7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,531 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "nvcurve" +version = "0.5.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "nvidia-ml-py" }, + { name = "pydantic" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "nvidia-ml-py", specifier = ">=12.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, +] + +[[package]] +name = "nvidia-ml-py" +version = "13.590.48" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/a0/f4fc18cf72f06821a9a665085435b901449986855519d5b3843532db35e9/nvidia_ml_py-13.590.48.tar.gz", hash = "sha256:8184d1be52914ac7f0991cd1c0d946c65dc88a840c754cd12c274b77b88760dd", size = 49732, upload-time = "2026-01-22T01:14:56.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]