github-actions[bot] commited on
Commit
1b50562
·
0 Parent(s):

Deploy to HF Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +38 -0
  2. .github/workflows/ci.yml +86 -0
  3. .github/workflows/hf-deploy.yml +47 -0
  4. .gitignore +56 -0
  5. .readthedocs.yml +16 -0
  6. DEVELOPMENT.md +114 -0
  7. Dockerfile +63 -0
  8. LICENSE +178 -0
  9. README.md +27 -0
  10. biome.json +35 -0
  11. examples/generate_random_runs.py +228 -0
  12. examples/slow_metrics_writer.py +85 -0
  13. icons.config.json +82 -0
  14. mkdocs.yml +89 -0
  15. package.json +46 -0
  16. playwright.config.js +68 -0
  17. pnpm-lock.yaml +2831 -0
  18. pyproject.toml +123 -0
  19. scripts/build-icons.js +119 -0
  20. space_README.md +27 -0
  21. src/aspara/__init__.py +31 -0
  22. src/aspara/catalog/__init__.py +18 -0
  23. src/aspara/catalog/project_catalog.py +202 -0
  24. src/aspara/catalog/run_catalog.py +738 -0
  25. src/aspara/catalog/watcher.py +507 -0
  26. src/aspara/cli.py +403 -0
  27. src/aspara/config.py +214 -0
  28. src/aspara/dashboard/__init__.py +5 -0
  29. src/aspara/dashboard/dependencies.py +120 -0
  30. src/aspara/dashboard/main.py +145 -0
  31. src/aspara/dashboard/models/__init__.py +3 -0
  32. src/aspara/dashboard/models/metrics.py +49 -0
  33. src/aspara/dashboard/router.py +25 -0
  34. src/aspara/dashboard/routes/__init__.py +14 -0
  35. src/aspara/dashboard/routes/api_routes.py +432 -0
  36. src/aspara/dashboard/routes/html_routes.py +234 -0
  37. src/aspara/dashboard/routes/sse_routes.py +239 -0
  38. src/aspara/dashboard/services/__init__.py +9 -0
  39. src/aspara/dashboard/services/template_service.py +162 -0
  40. src/aspara/dashboard/static/css/input.css +171 -0
  41. src/aspara/dashboard/static/css/tagger.css +79 -0
  42. src/aspara/dashboard/static/favicon.ico +0 -0
  43. src/aspara/dashboard/static/images/aspara-icon.png +0 -0
  44. src/aspara/dashboard/static/js/api/delete-api.js +61 -0
  45. src/aspara/dashboard/static/js/chart.js +420 -0
  46. src/aspara/dashboard/static/js/chart/color-palette.js +198 -0
  47. src/aspara/dashboard/static/js/chart/controls.js +224 -0
  48. src/aspara/dashboard/static/js/chart/export-utils.js +74 -0
  49. src/aspara/dashboard/static/js/chart/export.js +140 -0
  50. src/aspara/dashboard/static/js/chart/interaction-utils.js +131 -0
.dockerignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Version control
2
+ .git/
3
+
4
+ # Virtual environments
5
+ .venv/
6
+
7
+ # Node modules (reinstalled in build)
8
+ node_modules/
9
+
10
+ # Build outputs (rebuilt in Docker)
11
+ src/aspara/dashboard/static/dist/
12
+
13
+ # Test artifacts
14
+ test-results/
15
+ playwright-report/
16
+ .coverage
17
+ htmlcov/
18
+ coverage/
19
+
20
+ # Documentation build
21
+ site/
22
+ docs/
23
+
24
+ # IDE / OS
25
+ .idea/
26
+ .DS_Store
27
+ Thumbs.db
28
+ *.swp
29
+ *.swo
30
+
31
+ # Cache
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+ __pycache__/
35
+
36
+ # Environment files
37
+ .env
38
+ .env.*
.github/workflows/ci.yml ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ # Security note for public repositories:
4
+ # If this repository becomes public, configure the following in
5
+ # Settings > Actions > General > "Fork pull request workflows from outside collaborators":
6
+ # Set to "Require approval for all outside collaborators"
7
+
8
+ on:
9
+ pull_request:
10
+ branches: [main]
11
+
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v7
22
+ with:
23
+ enable-cache: true
24
+ - run: uv sync --dev --locked
25
+ - run: uv run ruff check .
26
+ - run: uv run ruff format --check .
27
+ - uses: pnpm/action-setup@v4
28
+ with:
29
+ version: 10.6.3
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: '22'
33
+ cache: 'pnpm'
34
+ - run: pnpm install --frozen-lockfile
35
+ - run: pnpm lint
36
+
37
+ python-test:
38
+ runs-on: ubuntu-latest
39
+ strategy:
40
+ matrix:
41
+ python-version: ['3.10', '3.14']
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: pnpm/action-setup@v4
45
+ with:
46
+ version: 10.6.3
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: '22'
50
+ cache: 'pnpm'
51
+ - run: pnpm install --frozen-lockfile
52
+ - run: pnpm build
53
+ - uses: astral-sh/setup-uv@v7
54
+ with:
55
+ enable-cache: true
56
+ python-version: ${{ matrix.python-version }}
57
+ - run: uv sync --dev --locked
58
+ - run: uv run pytest
59
+
60
+ js-test:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: pnpm/action-setup@v4
65
+ with:
66
+ version: 10.6.3
67
+ - uses: actions/setup-node@v4
68
+ with:
69
+ node-version: '22'
70
+ cache: 'pnpm'
71
+ - run: pnpm install --frozen-lockfile
72
+ - run: pnpm test:ci
73
+
74
+ build:
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ - uses: pnpm/action-setup@v4
79
+ with:
80
+ version: 10.6.3
81
+ - uses: actions/setup-node@v4
82
+ with:
83
+ node-version: '22'
84
+ cache: 'pnpm'
85
+ - run: pnpm install --frozen-lockfile
86
+ - run: pnpm build
.github/workflows/hf-deploy.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HF Spaces
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths-ignore:
7
+ - "docs/**"
8
+ - "mkdocs.yml"
9
+ - "tests/**"
10
+ - "*.md"
11
+ workflow_dispatch:
12
+
13
+ concurrency:
14
+ group: hf-deploy
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ deploy:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Fetch HF Space config
24
+ run: |
25
+ git fetch origin hf-space
26
+ git checkout origin/hf-space -- Dockerfile .dockerignore space_README.md
27
+
28
+ - name: Prepare HF Space
29
+ run: |
30
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
31
+ git config user.name "github-actions[bot]"
32
+
33
+ # Create orphan branch (no history) to avoid binary files in past commits
34
+ git checkout --orphan hf-deploy
35
+ git rm -rf --cached docs/ tests/ >/dev/null 2>&1 || true
36
+ rm -rf docs/ tests/
37
+ cp space_README.md README.md
38
+ git add -A
39
+ git commit -m "Deploy to HF Spaces"
40
+
41
+ - name: Push to Hugging Face
42
+ env:
43
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
44
+ run: |
45
+ git push --force \
46
+ https://hf:${HF_TOKEN}@huggingface.co/spaces/PredNext/aspara \
47
+ HEAD:main
.gitignore ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+ *.whl
10
+
11
+ # Virtual environment
12
+ .venv/
13
+
14
+ # Node.js
15
+ node_modules/
16
+
17
+ # Build output
18
+ src/aspara/dashboard/static/dist/
19
+
20
+ # Test
21
+ test-results/
22
+ playwright-report/
23
+ .coverage
24
+ htmlcov/
25
+ coverage/
26
+
27
+ # Logs
28
+ logs/
29
+ *.log
30
+
31
+ # OS
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # IDE
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+
40
+ # Linter / Type checker cache
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+
44
+ # Environment variables
45
+ .env
46
+ .env.*
47
+
48
+ # Database
49
+ *.sqlite
50
+ *.db
51
+
52
+ # uv
53
+ .python-version
54
+
55
+ # MkDocs
56
+ site/
.readthedocs.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+
3
+ build:
4
+ os: "ubuntu-24.04"
5
+ tools:
6
+ python: "3.12"
7
+
8
+ mkdocs:
9
+ configuration: mkdocs.yml
10
+
11
+ python:
12
+ install:
13
+ - method: pip
14
+ path: .
15
+ extra_requirements:
16
+ - docs
DEVELOPMENT.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development Guide
2
+
3
+ This document is a developer guide for Aspara.
4
+
5
+ ## Setup
6
+
7
+ ### Python dependencies
8
+
9
+ ```bash
10
+ uv sync --dev
11
+ ```
12
+
13
+ ### JavaScript dependencies
14
+
15
+ ```bash
16
+ pnpm install
17
+ ```
18
+
19
+ ## Building Assets
20
+
21
+ After cloning the repository, you must build frontend assets before running Aspara.
22
+ These build artifacts are not tracked in git, but are included in pip packages.
23
+
24
+ ### Build all assets (CSS + JavaScript)
25
+
26
+ ```bash
27
+ pnpm build
28
+ ```
29
+
30
+ This command generates:
31
+ - CSS: `src/aspara/dashboard/static/dist/css/styles.css`
32
+ - JavaScript: `src/aspara/dashboard/static/dist/*.js`
33
+
34
+ ### Build CSS only
35
+
36
+ ```bash
37
+ pnpm run build:css
38
+ ```
39
+
40
+ ### Build JavaScript only
41
+
42
+ ```bash
43
+ pnpm run build:js
44
+ ```
45
+
46
+ ### Development mode (watch mode)
47
+
48
+ To automatically detect file changes and rebuild during development:
49
+
50
+ ```bash
51
+ # Watch CSS
52
+ pnpm run watch:css
53
+
54
+ # Watch JavaScript
55
+ pnpm run watch:js
56
+ ```
57
+
58
+ ## Testing
59
+
60
+ ### Python tests
61
+
62
+ ```bash
63
+ uv run pytest
64
+ ```
65
+
66
+ ### JavaScript tests
67
+
68
+ ```bash
69
+ pnpm test
70
+ ```
71
+
72
+ ### E2E tests
73
+
74
+ ```bash
75
+ npx playwright test
76
+ ```
77
+
78
+ ## Linting and Formatting
79
+
80
+ ### Python
81
+
82
+ ```bash
83
+ # Lint
84
+ ruff check .
85
+
86
+ # Format
87
+ ruff format .
88
+ ```
89
+
90
+ ### JavaScript
91
+
92
+ ```bash
93
+ # Lint
94
+ pnpm lint
95
+
96
+ # Format
97
+ pnpm format
98
+ ```
99
+
100
+ ## Documentation
101
+
102
+ ### Build documentation
103
+
104
+ ```bash
105
+ uv run mkdocs build
106
+ ```
107
+
108
+ ### Serve documentation locally
109
+
110
+ ```bash
111
+ uv run mkdocs serve
112
+ ```
113
+
114
+ You can view the documentation by accessing http://localhost:8000 in your browser.
Dockerfile ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================================================
2
+ # Aspara Demo - Hugging Face Spaces
3
+ # Multi-stage build: frontend (Node.js) + backend (Python/FastAPI)
4
+ # ==============================================================================
5
+
6
+ # ------------------------------------------------------------------------------
7
+ # Stage 1: Frontend build (JS + CSS + icons)
8
+ # ------------------------------------------------------------------------------
9
+ FROM node:22-slim AS frontend-builder
10
+
11
+ WORKDIR /app
12
+
13
+ # Enable pnpm via corepack
14
+ RUN corepack enable && corepack prepare pnpm@10.6.3 --activate
15
+
16
+ # Install JS dependencies (cache layer)
17
+ COPY package.json pnpm-lock.yaml ./
18
+ RUN pnpm install --frozen-lockfile
19
+
20
+ # Copy source and build frontend assets
21
+ COPY vite.config.js icons.config.json ./
22
+ COPY scripts/ ./scripts/
23
+ COPY src/aspara/dashboard/ ./src/aspara/dashboard/
24
+ RUN pnpm run build:icons && pnpm run build:js && pnpm run build:css
25
+
26
+ # ------------------------------------------------------------------------------
27
+ # Stage 2: Python runtime + sample data generation
28
+ # ------------------------------------------------------------------------------
29
+ FROM python:3.12-slim
30
+
31
+ WORKDIR /app
32
+
33
+ # Install uv
34
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
35
+
36
+ # Copy Python project files
37
+ COPY pyproject.toml uv.lock ./
38
+ COPY space_README.md ./README.md
39
+ COPY src/ ./src/
40
+
41
+ # Install Python dependencies (dashboard extra only, no dev deps)
42
+ RUN uv sync --frozen --extra dashboard --no-dev
43
+
44
+ # Overwrite with built frontend assets
45
+ COPY --from=frontend-builder /app/src/aspara/dashboard/static/dist/ ./src/aspara/dashboard/static/dist/
46
+
47
+ # Generate sample data during build
48
+ COPY examples/generate_random_runs.py ./examples/
49
+ ENV ASPARA_DATA_DIR=/data/aspara
50
+ ENV ASPARA_ALLOW_IFRAME=1
51
+ ENV ASPARA_READ_ONLY=1
52
+ RUN mkdir -p /data/aspara && uv run python examples/generate_random_runs.py
53
+
54
+ # Create non-root user (HF Spaces best practice)
55
+ RUN useradd -m -u 1000 user && \
56
+ chown -R user:user /data /app
57
+ USER user
58
+
59
+ # HF Spaces uses port 7860
60
+ EXPOSE 7860
61
+
62
+ # Start dashboard only (no tracker = no external write API)
63
+ CMD ["uv", "run", "aspara", "serve", "--host", "0.0.0.0", "--port", "7860", "--data-dir", "/data/aspara"]
LICENSE ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to the Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no theory of
154
+ liability, whether in contract, strict liability, or tort
155
+ (including negligence or otherwise) arising in any way out of
156
+ the use or inability to use the Work (even if such Holder has
157
+ been advised of the possibility of such damages), shall any
158
+ Contributor be liable to You for damages, including any direct,
159
+ indirect, special, incidental, or consequential damages of any
160
+ character arising as a result of this License or out of the use
161
+ or inability to use the Work (including but not limited to
162
+ damages for loss of goodwill, work stoppage, computer failure or
163
+ malfunction, or any and all other commercial damages or losses),
164
+ even if such Contributor has been advised of the possibility of
165
+ such damages.
166
+
167
+ 9. Accepting Warranty or Additional Liability. While redistributing
168
+ the Work or Derivative Works thereof, You may choose to offer,
169
+ and charge a fee for, acceptance of support, warranty, indemnity,
170
+ or other liability obligations and/or rights consistent with this
171
+ License. However, in accepting such obligations, You may act only
172
+ on Your own behalf and on Your sole responsibility, not on behalf
173
+ of any other Contributor, and only if You agree to indemnify,
174
+ defend, and hold each Contributor harmless for any liability
175
+ incurred by, or claims asserted against, such Contributor by reason
176
+ of your accepting any such warranty or additional liability.
177
+
178
+ END OF TERMS AND CONDITIONS
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Aspara Demo
3
+ emoji: 🌱
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Aspara Demo
12
+
13
+ Aspara — a blazingly fast metrics tracker for machine learning experiments.
14
+
15
+ This Space runs a demo dashboard with pre-generated sample data.
16
+ Browse projects, compare runs, and explore metrics to see what Aspara can do.
17
+
18
+ ## Features
19
+
20
+ - LTTB-based metric downsampling for responsive charts
21
+ - Run comparison with overlay charts
22
+ - Tag and note editing
23
+ - Real-time updates via SSE
24
+
25
+ ## Links
26
+
27
+ - [GitHub Repository](https://github.com/prednext/aspara)
biome.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "organizeImports": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true,
10
+ "suspicious": {
11
+ "noExplicitAny": "off"
12
+ },
13
+ "style": {
14
+ "useImportType": "off"
15
+ }
16
+ }
17
+ },
18
+ "formatter": {
19
+ "enabled": true,
20
+ "indentStyle": "space",
21
+ "indentWidth": 2,
22
+ "lineWidth": 160
23
+ },
24
+ "javascript": {
25
+ "formatter": {
26
+ "quoteStyle": "single",
27
+ "semicolons": "always",
28
+ "trailingCommas": "es5"
29
+ }
30
+ },
31
+ "files": {
32
+ "include": ["src/**/*.js", "tests/**/*.js", "*.js"],
33
+ "ignore": ["node_modules/**", "dist/**", "build/**", "docs/**", "site/**", "site.old/**", "playwright-report/**", ".venv", "coverage/**"]
34
+ }
35
+ }
examples/generate_random_runs.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sample script to generate multiple random experiment runs.
3
+ Creates 4 different runs, each recording 100 steps of metrics.
4
+ """
5
+
6
+ import math
7
+ import random
8
+
9
+ import aspara
10
+
11
+
12
+ def generate_metrics_with_trend(
13
+ step: int,
14
+ total_steps: int,
15
+ base_values: dict[str, float],
16
+ noise_levels: dict[str, float],
17
+ trends: dict[str, float],
18
+ ) -> dict[str, float]:
19
+ """
20
+ Generate metrics with trend and noise.
21
+
22
+ Args:
23
+ step: Current step
24
+ total_steps: Total number of steps
25
+ base_values: Initial values for each metric
26
+ noise_levels: Noise level for each metric
27
+ trends: Final change amount for each metric
28
+
29
+ Returns:
30
+ Generated metrics
31
+ """
32
+ progress = step / total_steps
33
+ metrics = {}
34
+
35
+ for metric_name, base_value in base_values.items():
36
+ # Change due to trend (linear + slight exponential component)
37
+ trend_factor = progress * (1.0 + 0.2 * math.log(1 + 5 * progress))
38
+ trend_change = trends[metric_name] * trend_factor
39
+
40
+ # Random noise (sine wave + Gaussian noise)
41
+ noise = (
42
+ noise_levels[metric_name] * math.sin(step * 0.2) * 0.3 # Periodic noise
43
+ + noise_levels[metric_name] * random.gauss(0, 0.5) # Random noise
44
+ )
45
+
46
+ # Calculate final value
47
+ value = base_value + trend_change + noise
48
+
49
+ # Limit value range (accuracy between 0-1, loss >= 0)
50
+ if "accuracy" in metric_name:
51
+ value = max(0.0, min(1.0, value))
52
+ elif "loss" in metric_name:
53
+ value = max(0.01, value)
54
+
55
+ metrics[metric_name] = value
56
+
57
+ return metrics
58
+
59
+
60
+ def create_run_config(run_id: int) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
61
+ """
62
+ Create configuration for each run.
63
+
64
+ Args:
65
+ run_id: Run number
66
+
67
+ Returns:
68
+ Tuple of (initial values, noise levels, trends)
69
+ """
70
+ # Set slightly different initial values for each run
71
+ base_values = {
72
+ "accuracy": 0.3 + random.uniform(-0.1, 0.1),
73
+ "loss": 1.0 + random.uniform(-0.2, 0.2),
74
+ "val_accuracy": 0.25 + random.uniform(-0.1, 0.1),
75
+ "val_loss": 1.1 + random.uniform(-0.2, 0.2),
76
+ }
77
+
78
+ # Set noise levels
79
+ noise_levels = {
80
+ "accuracy": 0.02 + 0.01 * run_id,
81
+ "loss": 0.05 + 0.02 * run_id,
82
+ "val_accuracy": 0.03 + 0.01 * run_id,
83
+ "val_loss": 0.07 + 0.02 * run_id,
84
+ }
85
+
86
+ # Set trends (accuracy increases, loss decreases)
87
+ trends = {
88
+ "accuracy": 0.5 + random.uniform(-0.1, 0.1), # Upward trend
89
+ "loss": -0.8 + random.uniform(-0.1, 0.1), # Downward trend
90
+ "val_accuracy": 0.45 + random.uniform(-0.1, 0.1), # Upward trend (slightly lower than train)
91
+ "val_loss": -0.75 + random.uniform(-0.1, 0.1), # Downward trend (slightly higher than train)
92
+ }
93
+
94
+ return base_values, noise_levels, trends
95
+
96
+
97
+ def generate_run(
98
+ project: str,
99
+ run_id: int,
100
+ total_steps: int = 100,
101
+ project_tags: list[str] | None = None,
102
+ run_name: str | None = None,
103
+ ) -> None:
104
+ """
105
+ Generate an experiment run with the specified ID.
106
+
107
+ Args:
108
+ project: Project name
109
+ run_id: Run number
110
+ total_steps: Number of steps to generate
111
+ project_tags: Common tags for the project
112
+ run_name: Run name (generated from run_id if not specified)
113
+ """
114
+ # Initialize run
115
+ if run_name is None:
116
+ run_name = f"random_training_run_{run_id}"
117
+
118
+ print(f"Starting generation of run {run_id} for project '{project}'! ({run_name})")
119
+
120
+ # Create run configuration
121
+ base_values, noise_levels, trends = create_run_config(run_id)
122
+
123
+ # Add run-specific tags (fruits) to project-common tags (animals)
124
+ fruits = ["apple", "pear", "orange", "grape", "banana", "mango"]
125
+ num_fruit_tags = random.randint(1, len(fruits))
126
+ run_tags = random.sample(fruits, k=num_fruit_tags)
127
+
128
+ aspara.init(
129
+ project=project,
130
+ name=run_name,
131
+ config={
132
+ "learning_rate": 0.01 * (1 + 0.2 * run_id),
133
+ "batch_size": 32 * (1 + run_id % 2),
134
+ "optimizer": ["adam", "sgd", "rmsprop", "adagrad"][run_id % 4],
135
+ "model_type": "mlp",
136
+ "hidden_layers": [128, 64, 32],
137
+ "dropout": 0.2 + 0.05 * run_id,
138
+ "epochs": 10,
139
+ "run_id": run_id,
140
+ },
141
+ tags=run_tags,
142
+ project_tags=project_tags,
143
+ )
144
+
145
+ # Simulate training loop
146
+ print(f"Generating metrics for {total_steps} steps...")
147
+ for step in range(total_steps):
148
+ # Generate metrics
149
+ metrics = generate_metrics_with_trend(step, total_steps, base_values, noise_levels, trends)
150
+
151
+ # Log metrics
152
+ aspara.log(metrics, step=step)
153
+
154
+ # Show progress (every 10 steps)
155
+ if step % 10 == 0 or step == total_steps - 1:
156
+ print(f" Step {step}/{total_steps - 1}: accuracy={metrics['accuracy']:.3f}, loss={metrics['loss']:.3f}")
157
+
158
+ # Finish run
159
+ aspara.finish()
160
+
161
+ print(f"Completed generation of run {run_id} for project '{project}'!")
162
+
163
+
164
+ def main() -> None:
165
+ """Main function: Generate multiple runs."""
166
+ steps_per_run = 100
167
+
168
+ # Cool secret project names
169
+ project_names = [
170
+ "Project_Phoenix",
171
+ "Operation_Midnight",
172
+ "Genesis_Initiative",
173
+ "Project_Prometheus",
174
+ ]
175
+
176
+ # Famous SF titles (mix of Western and Japanese works)
177
+ sf_titles = [
178
+ "AKIRA",
179
+ "Ghost_in_the_Shell",
180
+ "Planetes",
181
+ "Steins_Gate",
182
+ "Paprika",
183
+ "Blade_Runner",
184
+ "Dune",
185
+ "Neuromancer",
186
+ "Foundation",
187
+ "The_Martian",
188
+ "Interstellar",
189
+ "Solaris",
190
+ "Hyperion",
191
+ "Snow_Crash",
192
+ "Contact",
193
+ "Arrival",
194
+ "Gravity",
195
+ "Moon",
196
+ "Ex_Machina",
197
+ "Tenet",
198
+ ]
199
+
200
+ print(f"Generating {len(project_names)} projects!")
201
+ print(f" Each project has 4-5 runs! ({steps_per_run} steps per run)")
202
+ animals = ["dog", "cat", "rabbit", "coala", "bear", "goat"]
203
+
204
+ # Shuffle SF titles before using
205
+ shuffled_sf_titles = sf_titles.copy()
206
+ random.shuffle(shuffled_sf_titles)
207
+ sf_title_index = 0
208
+
209
+ # Generate multiple projects, create 4-5 runs for each project
210
+ for project_name in project_names:
211
+ # Project-common tags (animals)
212
+ num_project_tags = random.randint(1, len(animals))
213
+ project_tags = random.sample(animals, k=num_project_tags)
214
+
215
+ num_runs = random.randint(4, 5)
216
+ for run_id in range(num_runs):
217
+ # Use SF title as run name
218
+ run_name = shuffled_sf_titles[sf_title_index % len(shuffled_sf_titles)]
219
+ sf_title_index += 1
220
+ generate_run(project_name, run_id, steps_per_run, project_tags, run_name)
221
+ print("") # Insert blank line
222
+
223
+ print("All runs have been generated!")
224
+ print(" Check them out on the dashboard!")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
examples/slow_metrics_writer.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simple slow metrics writer for testing SSE real-time updates.
3
+
4
+ This is a simpler version that's easy to customize.
5
+
6
+ Usage:
7
+ # Terminal 1: Start dashboard
8
+ aspara dashboard
9
+
10
+ # Terminal 2: Run this script
11
+ uv run python examples/slow_metrics_writer.py
12
+
13
+ # Terminal 3 (optional): Run again with different run name
14
+ uv run python examples/slow_metrics_writer.py --run experiment_2
15
+
16
+ # Open browser and watch metrics update in real-time!
17
+ """
18
+
19
+ import argparse
20
+ import math
21
+ import random
22
+ import time
23
+ from datetime import datetime
24
+
25
+ from aspara import Run
26
+
27
+
28
+ def main():
29
+ parser = argparse.ArgumentParser(description="Slow metrics writer for SSE testing")
30
+ parser.add_argument("--project", default="sse_test", help="Project name")
31
+ parser.add_argument("--run", default="experiment_1", help="Run name")
32
+ parser.add_argument("--steps", type=int, default=30, help="Number of steps")
33
+ parser.add_argument("--delay", type=float, default=2.0, help="Delay between steps (seconds)")
34
+ args = parser.parse_args()
35
+
36
+ # Random parameters for this run
37
+ run_seed = hash(args.run) % 1000
38
+ random.seed(run_seed)
39
+
40
+ loss_base = 1.2 + random.uniform(-0.2, 0.2)
41
+ acc_base = 0.3 + random.uniform(-0.1, 0.1)
42
+ noise_level = 0.015 + random.uniform(0, 0.01)
43
+
44
+ # Create run
45
+ run = Run(
46
+ project=args.project,
47
+ name=args.run,
48
+ tags=["sse", "test", "realtime"],
49
+ notes="Testing SSE real-time updates",
50
+ )
51
+
52
+ print(f"🚀 Writing metrics to {args.project}/{args.run}")
53
+ print(f" Steps: {args.steps}, Delay: {args.delay}s")
54
+ print(f" Base Loss: {loss_base:.3f}, Base Acc: {acc_base:.3f}")
55
+ print(" Open http://localhost:3141 to watch in real-time!\n")
56
+
57
+ # Write metrics gradually
58
+ for step in range(args.steps):
59
+ # Add noise (periodic + random)
60
+ noise = noise_level * (math.sin(step * 0.4) * 0.5 + random.gauss(0, 0.5))
61
+
62
+ # Simulate training metrics with noise
63
+ loss = max(0.01, (loss_base / (step + 1)) + noise)
64
+ accuracy = min(0.99, acc_base + (0.6 * (1.0 - 1.0 / (step + 1))) + noise * 0.3)
65
+
66
+ run.log(
67
+ {
68
+ "loss": loss,
69
+ "accuracy": accuracy,
70
+ "step_time": 0.1 + (step * 0.01) + random.uniform(-0.01, 0.01),
71
+ },
72
+ step=step,
73
+ )
74
+
75
+ timestamp = datetime.now().strftime("%H:%M:%S")
76
+ print(f"[{timestamp}] Step {step:3d}/{args.steps} | loss={loss:.4f} acc={accuracy:.4f}")
77
+
78
+ time.sleep(args.delay)
79
+
80
+ run.finish(exit_code=0)
81
+ print(f"\n✅ Completed! Total time: {args.steps * args.delay:.1f}s")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
icons.config.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "icons": [
3
+ {
4
+ "name": "stop",
5
+ "style": "solid",
6
+ "id": "status-icon-wip",
7
+ "comment": "Work in progress status"
8
+ },
9
+ {
10
+ "name": "x-mark",
11
+ "style": "outline",
12
+ "id": "status-icon-failed",
13
+ "comment": "Failed status"
14
+ },
15
+ {
16
+ "name": "check",
17
+ "style": "outline",
18
+ "id": "status-icon-completed",
19
+ "comment": "Completed status"
20
+ },
21
+ {
22
+ "name": "pencil-square",
23
+ "style": "outline",
24
+ "id": "icon-edit",
25
+ "comment": "Edit/pencil icon for note editing"
26
+ },
27
+ {
28
+ "name": "arrow-uturn-left",
29
+ "style": "outline",
30
+ "id": "icon-reset-zoom",
31
+ "comment": "Reset zoom for chart controls"
32
+ },
33
+ {
34
+ "name": "arrows-pointing-out",
35
+ "style": "outline",
36
+ "id": "icon-fullscreen",
37
+ "comment": "Full screen/expand for chart controls"
38
+ },
39
+ {
40
+ "name": "arrow-down-tray",
41
+ "style": "outline",
42
+ "id": "icon-download",
43
+ "comment": "Download for chart controls"
44
+ },
45
+ {
46
+ "name": "trash",
47
+ "style": "outline",
48
+ "id": "icon-delete",
49
+ "comment": "Delete/trash icon for delete buttons"
50
+ },
51
+ {
52
+ "name": "chevron-left",
53
+ "style": "outline",
54
+ "id": "icon-chevron-left",
55
+ "comment": "Chevron left for sidebar collapse"
56
+ },
57
+ {
58
+ "name": "chevron-right",
59
+ "style": "outline",
60
+ "id": "icon-chevron-right",
61
+ "comment": "Chevron right for sidebar expand"
62
+ },
63
+ {
64
+ "name": "bars-3",
65
+ "style": "outline",
66
+ "id": "icon-menu",
67
+ "comment": "Hamburger menu icon for settings"
68
+ },
69
+ {
70
+ "name": "exclamation-triangle",
71
+ "style": "outline",
72
+ "id": "icon-exclamation-triangle",
73
+ "comment": "Warning/danger icon for confirm modal and maybe_failed status"
74
+ },
75
+ {
76
+ "name": "information-circle",
77
+ "style": "outline",
78
+ "id": "icon-information-circle",
79
+ "comment": "Info icon for confirm modal"
80
+ }
81
+ ]
82
+ }
mkdocs.yml ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ site_name: Aspara User Manual
2
+ site_description: Aspara, blazingly fast metrics tracker for machine learning experiments
3
+ site_author: Aspara Development Group
4
+ copyright: Copyright © 2026 Aspara Development Group
5
+ use_directory_urls: false
6
+
7
+ theme:
8
+ name: material
9
+ language: en
10
+ logo: aspara-icon.png
11
+ favicon: aspara-icon.png
12
+ palette:
13
+ primary: indigo
14
+ accent: indigo
15
+ features:
16
+ - navigation.tabs
17
+ - navigation.sections
18
+ - navigation.top
19
+ - search.highlight
20
+ - content.code.copy
21
+
22
+ extra_css:
23
+ - aspara-theme.css
24
+
25
+ plugins:
26
+ - search
27
+ - mkdocstrings:
28
+ handlers:
29
+ python:
30
+ paths:
31
+ - src
32
+ selection:
33
+ docstring_style: google
34
+ rendering:
35
+ show_source: true
36
+ show_if_no_docstring: false
37
+ show_root_heading: false
38
+ show_root_toc_entry: false
39
+ heading_level: 2
40
+ show_signature_annotations: true
41
+ separate_signature: true
42
+ merge_init_into_class: false
43
+ docstring_section_style: "spacy"
44
+ show_symbol_type_heading: true
45
+ show_symbol_type_toc: true
46
+
47
+ markdown_extensions:
48
+ - pymdownx.highlight:
49
+ anchor_linenums: true
50
+ - pymdownx.superfences
51
+ - pymdownx.inlinehilite
52
+ - admonition
53
+ - pymdownx.details
54
+ - pymdownx.tabbed:
55
+ alternate_style: true
56
+ - tables
57
+ - footnotes
58
+
59
+ nav:
60
+ - Home: index.md
61
+ - Getting Started:
62
+ - Overview: getting-started.md
63
+ - User Guide:
64
+ - Overview: user-guide/basics.md
65
+ - Core Concepts: user-guide/concepts.md
66
+ - Metadata and Notes: user-guide/metadata.md
67
+ - Visualizing Results in Dashboard: user-guide/dashboard-visualization.md
68
+ - Terminal UI: user-guide/terminal-ui.md
69
+ - Best Practices: user-guide/best-practices.md
70
+ - Troubleshooting: user-guide/troubleshooting.md
71
+ - Advanced:
72
+ - Configuration: advanced/configuration.md
73
+ - LocalRun vs RemoteRun: advanced/local-vs-remote.md
74
+ - Storage: advanced/storage.md
75
+ - Dashboard: advanced/dashboard.md
76
+ - Tracker API: advanced/tracker-api.md
77
+ - Read-only Mode: advanced/read-only-mode.md
78
+ - Examples:
79
+ - Overview: examples/index.md
80
+ - PyTorch: examples/pytorch_example.md
81
+ - TensorFlow / Keras: examples/tensorflow_example.md
82
+ - scikit-learn: examples/sklearn_example.md
83
+ - API Reference:
84
+ - Overview: api/index.md
85
+ - aspara: api/aspara.md
86
+ - Run: api/run.md
87
+ - Dashboard API: api/dashboard.md
88
+ - Tracker API: api/tracker.md
89
+ - Contributing: contributing.md
package.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "aspara",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "vitest run",
9
+ "test:watch": "vitest",
10
+ "test:coverage": "vitest run --coverage",
11
+ "test:ui": "vitest --ui",
12
+ "test:ci": "vitest run --coverage",
13
+ "lint": "biome lint",
14
+ "format": "biome format --write",
15
+ "check": "biome check --write",
16
+ "build:icons": "node scripts/build-icons.js",
17
+ "build:js": "vite build",
18
+ "build:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --minify",
19
+ "watch:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --watch",
20
+ "build": "pnpm run build:icons && pnpm run build:js && pnpm run build:css"
21
+ },
22
+ "keywords": [],
23
+ "author": "",
24
+ "license": "Apache-2.0",
25
+ "packageManager": "pnpm@10.6.3",
26
+ "dependencies": {
27
+ "@jcubic/tagger": "^0.6.2",
28
+ "@msgpack/msgpack": "^3.1.3"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "^1.9.4",
32
+ "@playwright/test": "^1.58.2",
33
+ "@swc/core": "^1.15.17",
34
+ "@tailwindcss/cli": "^4.2.1",
35
+ "@testing-library/dom": "^10.4.1",
36
+ "@vitest/coverage-v8": "4.0.18",
37
+ "@vitest/ui": "^4.0.18",
38
+ "canvas": "npm:@napi-rs/canvas@^0.1.95",
39
+ "happy-dom": "^20.7.0",
40
+ "heroicons": "^2.2.0",
41
+ "jsdom": "^26.1.0",
42
+ "tailwindcss": "^4.2.1",
43
+ "vite": "^7.3.1",
44
+ "vitest": "^4.0.18"
45
+ }
46
+ }
playwright.config.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ import { defineConfig, devices } from '@playwright/test';
3
+
4
+ const BASE_PORT = 6113;
5
+
6
+ /**
7
+ * @see https://playwright.dev/docs/test-configuration
8
+ */
9
+ export default defineConfig({
10
+ // E2Eテストのみを対象にする(Vitestとの競合を避ける)
11
+ testDir: './tests/e2e',
12
+
13
+ // 並列実行の worker 数
14
+ // CI では 2 workers、ローカルでは制限なし
15
+ workers: process.env.CI ? 2 : undefined,
16
+
17
+ // テストの実行タイムアウト
18
+ timeout: 30 * 1000,
19
+
20
+ // テスト実行の期待値
21
+ expect: {
22
+ // 要素が表示されるまでの最大待機時間
23
+ timeout: 5000,
24
+ },
25
+
26
+ // 失敗したテストのスクリーンショットを撮る
27
+ use: {
28
+ // ベースURL
29
+ baseURL: `http://localhost:${BASE_PORT}`,
30
+
31
+ // スクリーンショットを撮る
32
+ screenshot: 'only-on-failure',
33
+
34
+ // トレースを記録する
35
+ trace: 'on-first-retry',
36
+
37
+ // ダウンロードを許可
38
+ acceptDownloads: true,
39
+ },
40
+
41
+ // テスト実行のレポート形式
42
+ // 'list' はコンソール出力のみ、HTMLレポートは生成しない
43
+ reporter: process.env.CI ? 'github' : 'list',
44
+
45
+ // テスト前にサーバーを自動起動
46
+ webServer: {
47
+ command: `uv run aspara dashboard --port ${BASE_PORT}`,
48
+ port: BASE_PORT,
49
+ reuseExistingServer: !process.env.CI,
50
+ timeout: 60 * 1000,
51
+ },
52
+
53
+ // プロジェクト設定
54
+ projects: [
55
+ {
56
+ name: 'chromium',
57
+ use: { ...devices['Desktop Chrome'] },
58
+ },
59
+ {
60
+ name: 'firefox',
61
+ use: { ...devices['Desktop Firefox'] },
62
+ },
63
+ {
64
+ name: 'webkit',
65
+ use: { ...devices['Desktop Safari'] },
66
+ },
67
+ ],
68
+ });
pnpm-lock.yaml ADDED
@@ -0,0 +1,2831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ lockfileVersion: '9.0'
2
+
3
+ settings:
4
+ autoInstallPeers: true
5
+ excludeLinksFromLockfile: false
6
+
7
+ importers:
8
+
9
+ .:
10
+ dependencies:
11
+ '@jcubic/tagger':
12
+ specifier: ^0.6.2
13
+ version: 0.6.2
14
+ '@msgpack/msgpack':
15
+ specifier: ^3.1.3
16
+ version: 3.1.3
17
+ devDependencies:
18
+ '@biomejs/biome':
19
+ specifier: ^1.9.4
20
+ version: 1.9.4
21
+ '@playwright/test':
22
+ specifier: ^1.58.2
23
+ version: 1.58.2
24
+ '@swc/core':
25
+ specifier: ^1.15.17
26
+ version: 1.15.17
27
+ '@tailwindcss/cli':
28
+ specifier: ^4.2.1
29
+ version: 4.2.1
30
+ '@testing-library/dom':
31
+ specifier: ^10.4.1
32
+ version: 10.4.1
33
+ '@vitest/coverage-v8':
34
+ specifier: 4.0.18
35
+ version: 4.0.18(vitest@4.0.18)
36
+ '@vitest/ui':
37
+ specifier: ^4.0.18
38
+ version: 4.0.18(vitest@4.0.18)
39
+ canvas:
40
+ specifier: npm:@napi-rs/canvas@^0.1.95
41
+ version: '@napi-rs/canvas@0.1.95'
42
+ happy-dom:
43
+ specifier: ^20.7.0
44
+ version: 20.7.0
45
+ heroicons:
46
+ specifier: ^2.2.0
47
+ version: 2.2.0
48
+ jsdom:
49
+ specifier: ^26.1.0
50
+ version: 26.1.0(@napi-rs/canvas@0.1.95)
51
+ tailwindcss:
52
+ specifier: ^4.2.1
53
+ version: 4.2.1
54
+ vite:
55
+ specifier: ^7.3.1
56
+ version: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
57
+ vitest:
58
+ specifier: ^4.0.18
59
+ version: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
60
+
61
+ packages:
62
+
63
+ '@asamuzakjp/css-color@3.2.0':
64
+ resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
65
+
66
+ '@babel/code-frame@7.29.0':
67
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
68
+ engines: {node: '>=6.9.0'}
69
+
70
+ '@babel/helper-string-parser@7.27.1':
71
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
72
+ engines: {node: '>=6.9.0'}
73
+
74
+ '@babel/helper-validator-identifier@7.28.5':
75
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
76
+ engines: {node: '>=6.9.0'}
77
+
78
+ '@babel/parser@7.29.0':
79
+ resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
80
+ engines: {node: '>=6.0.0'}
81
+ hasBin: true
82
+
83
+ '@babel/runtime@7.28.6':
84
+ resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
85
+ engines: {node: '>=6.9.0'}
86
+
87
+ '@babel/types@7.29.0':
88
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
89
+ engines: {node: '>=6.9.0'}
90
+
91
+ '@bcoe/v8-coverage@1.0.2':
92
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
93
+ engines: {node: '>=18'}
94
+
95
+ '@biomejs/biome@1.9.4':
96
+ resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
97
+ engines: {node: '>=14.21.3'}
98
+ hasBin: true
99
+
100
+ '@biomejs/cli-darwin-arm64@1.9.4':
101
+ resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
102
+ engines: {node: '>=14.21.3'}
103
+ cpu: [arm64]
104
+ os: [darwin]
105
+
106
+ '@biomejs/cli-darwin-x64@1.9.4':
107
+ resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
108
+ engines: {node: '>=14.21.3'}
109
+ cpu: [x64]
110
+ os: [darwin]
111
+
112
+ '@biomejs/cli-linux-arm64-musl@1.9.4':
113
+ resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
114
+ engines: {node: '>=14.21.3'}
115
+ cpu: [arm64]
116
+ os: [linux]
117
+
118
+ '@biomejs/cli-linux-arm64@1.9.4':
119
+ resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
120
+ engines: {node: '>=14.21.3'}
121
+ cpu: [arm64]
122
+ os: [linux]
123
+
124
+ '@biomejs/cli-linux-x64-musl@1.9.4':
125
+ resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
126
+ engines: {node: '>=14.21.3'}
127
+ cpu: [x64]
128
+ os: [linux]
129
+
130
+ '@biomejs/cli-linux-x64@1.9.4':
131
+ resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
132
+ engines: {node: '>=14.21.3'}
133
+ cpu: [x64]
134
+ os: [linux]
135
+
136
+ '@biomejs/cli-win32-arm64@1.9.4':
137
+ resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
138
+ engines: {node: '>=14.21.3'}
139
+ cpu: [arm64]
140
+ os: [win32]
141
+
142
+ '@biomejs/cli-win32-x64@1.9.4':
143
+ resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
144
+ engines: {node: '>=14.21.3'}
145
+ cpu: [x64]
146
+ os: [win32]
147
+
148
+ '@csstools/color-helpers@5.1.0':
149
+ resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
150
+ engines: {node: '>=18'}
151
+
152
+ '@csstools/css-calc@2.1.4':
153
+ resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
154
+ engines: {node: '>=18'}
155
+ peerDependencies:
156
+ '@csstools/css-parser-algorithms': ^3.0.5
157
+ '@csstools/css-tokenizer': ^3.0.4
158
+
159
+ '@csstools/css-color-parser@3.1.0':
160
+ resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
161
+ engines: {node: '>=18'}
162
+ peerDependencies:
163
+ '@csstools/css-parser-algorithms': ^3.0.5
164
+ '@csstools/css-tokenizer': ^3.0.4
165
+
166
+ '@csstools/css-parser-algorithms@3.0.5':
167
+ resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
168
+ engines: {node: '>=18'}
169
+ peerDependencies:
170
+ '@csstools/css-tokenizer': ^3.0.4
171
+
172
+ '@csstools/css-tokenizer@3.0.4':
173
+ resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
174
+ engines: {node: '>=18'}
175
+
176
+ '@esbuild/aix-ppc64@0.25.12':
177
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
178
+ engines: {node: '>=18'}
179
+ cpu: [ppc64]
180
+ os: [aix]
181
+
182
+ '@esbuild/aix-ppc64@0.27.3':
183
+ resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
184
+ engines: {node: '>=18'}
185
+ cpu: [ppc64]
186
+ os: [aix]
187
+
188
+ '@esbuild/android-arm64@0.25.12':
189
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
190
+ engines: {node: '>=18'}
191
+ cpu: [arm64]
192
+ os: [android]
193
+
194
+ '@esbuild/android-arm64@0.27.3':
195
+ resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
196
+ engines: {node: '>=18'}
197
+ cpu: [arm64]
198
+ os: [android]
199
+
200
+ '@esbuild/android-arm@0.25.12':
201
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
202
+ engines: {node: '>=18'}
203
+ cpu: [arm]
204
+ os: [android]
205
+
206
+ '@esbuild/android-arm@0.27.3':
207
+ resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
208
+ engines: {node: '>=18'}
209
+ cpu: [arm]
210
+ os: [android]
211
+
212
+ '@esbuild/android-x64@0.25.12':
213
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
214
+ engines: {node: '>=18'}
215
+ cpu: [x64]
216
+ os: [android]
217
+
218
+ '@esbuild/android-x64@0.27.3':
219
+ resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
220
+ engines: {node: '>=18'}
221
+ cpu: [x64]
222
+ os: [android]
223
+
224
+ '@esbuild/darwin-arm64@0.25.12':
225
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
226
+ engines: {node: '>=18'}
227
+ cpu: [arm64]
228
+ os: [darwin]
229
+
230
+ '@esbuild/darwin-arm64@0.27.3':
231
+ resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
232
+ engines: {node: '>=18'}
233
+ cpu: [arm64]
234
+ os: [darwin]
235
+
236
+ '@esbuild/darwin-x64@0.25.12':
237
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
238
+ engines: {node: '>=18'}
239
+ cpu: [x64]
240
+ os: [darwin]
241
+
242
+ '@esbuild/darwin-x64@0.27.3':
243
+ resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
244
+ engines: {node: '>=18'}
245
+ cpu: [x64]
246
+ os: [darwin]
247
+
248
+ '@esbuild/freebsd-arm64@0.25.12':
249
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
250
+ engines: {node: '>=18'}
251
+ cpu: [arm64]
252
+ os: [freebsd]
253
+
254
+ '@esbuild/freebsd-arm64@0.27.3':
255
+ resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
256
+ engines: {node: '>=18'}
257
+ cpu: [arm64]
258
+ os: [freebsd]
259
+
260
+ '@esbuild/freebsd-x64@0.25.12':
261
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
262
+ engines: {node: '>=18'}
263
+ cpu: [x64]
264
+ os: [freebsd]
265
+
266
+ '@esbuild/freebsd-x64@0.27.3':
267
+ resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
268
+ engines: {node: '>=18'}
269
+ cpu: [x64]
270
+ os: [freebsd]
271
+
272
+ '@esbuild/linux-arm64@0.25.12':
273
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
274
+ engines: {node: '>=18'}
275
+ cpu: [arm64]
276
+ os: [linux]
277
+
278
+ '@esbuild/linux-arm64@0.27.3':
279
+ resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
280
+ engines: {node: '>=18'}
281
+ cpu: [arm64]
282
+ os: [linux]
283
+
284
+ '@esbuild/linux-arm@0.25.12':
285
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
286
+ engines: {node: '>=18'}
287
+ cpu: [arm]
288
+ os: [linux]
289
+
290
+ '@esbuild/linux-arm@0.27.3':
291
+ resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
292
+ engines: {node: '>=18'}
293
+ cpu: [arm]
294
+ os: [linux]
295
+
296
+ '@esbuild/linux-ia32@0.25.12':
297
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
298
+ engines: {node: '>=18'}
299
+ cpu: [ia32]
300
+ os: [linux]
301
+
302
+ '@esbuild/linux-ia32@0.27.3':
303
+ resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
304
+ engines: {node: '>=18'}
305
+ cpu: [ia32]
306
+ os: [linux]
307
+
308
+ '@esbuild/linux-loong64@0.25.12':
309
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
310
+ engines: {node: '>=18'}
311
+ cpu: [loong64]
312
+ os: [linux]
313
+
314
+ '@esbuild/linux-loong64@0.27.3':
315
+ resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
316
+ engines: {node: '>=18'}
317
+ cpu: [loong64]
318
+ os: [linux]
319
+
320
+ '@esbuild/linux-mips64el@0.25.12':
321
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
322
+ engines: {node: '>=18'}
323
+ cpu: [mips64el]
324
+ os: [linux]
325
+
326
+ '@esbuild/linux-mips64el@0.27.3':
327
+ resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
328
+ engines: {node: '>=18'}
329
+ cpu: [mips64el]
330
+ os: [linux]
331
+
332
+ '@esbuild/linux-ppc64@0.25.12':
333
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
334
+ engines: {node: '>=18'}
335
+ cpu: [ppc64]
336
+ os: [linux]
337
+
338
+ '@esbuild/linux-ppc64@0.27.3':
339
+ resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
340
+ engines: {node: '>=18'}
341
+ cpu: [ppc64]
342
+ os: [linux]
343
+
344
+ '@esbuild/linux-riscv64@0.25.12':
345
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
346
+ engines: {node: '>=18'}
347
+ cpu: [riscv64]
348
+ os: [linux]
349
+
350
+ '@esbuild/linux-riscv64@0.27.3':
351
+ resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
352
+ engines: {node: '>=18'}
353
+ cpu: [riscv64]
354
+ os: [linux]
355
+
356
+ '@esbuild/linux-s390x@0.25.12':
357
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
358
+ engines: {node: '>=18'}
359
+ cpu: [s390x]
360
+ os: [linux]
361
+
362
+ '@esbuild/linux-s390x@0.27.3':
363
+ resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
364
+ engines: {node: '>=18'}
365
+ cpu: [s390x]
366
+ os: [linux]
367
+
368
+ '@esbuild/linux-x64@0.25.12':
369
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
370
+ engines: {node: '>=18'}
371
+ cpu: [x64]
372
+ os: [linux]
373
+
374
+ '@esbuild/linux-x64@0.27.3':
375
+ resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
376
+ engines: {node: '>=18'}
377
+ cpu: [x64]
378
+ os: [linux]
379
+
380
+ '@esbuild/netbsd-arm64@0.25.12':
381
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
382
+ engines: {node: '>=18'}
383
+ cpu: [arm64]
384
+ os: [netbsd]
385
+
386
+ '@esbuild/netbsd-arm64@0.27.3':
387
+ resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
388
+ engines: {node: '>=18'}
389
+ cpu: [arm64]
390
+ os: [netbsd]
391
+
392
+ '@esbuild/netbsd-x64@0.25.12':
393
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
394
+ engines: {node: '>=18'}
395
+ cpu: [x64]
396
+ os: [netbsd]
397
+
398
+ '@esbuild/netbsd-x64@0.27.3':
399
+ resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
400
+ engines: {node: '>=18'}
401
+ cpu: [x64]
402
+ os: [netbsd]
403
+
404
+ '@esbuild/openbsd-arm64@0.25.12':
405
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
406
+ engines: {node: '>=18'}
407
+ cpu: [arm64]
408
+ os: [openbsd]
409
+
410
+ '@esbuild/openbsd-arm64@0.27.3':
411
+ resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
412
+ engines: {node: '>=18'}
413
+ cpu: [arm64]
414
+ os: [openbsd]
415
+
416
+ '@esbuild/openbsd-x64@0.25.12':
417
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
418
+ engines: {node: '>=18'}
419
+ cpu: [x64]
420
+ os: [openbsd]
421
+
422
+ '@esbuild/openbsd-x64@0.27.3':
423
+ resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
424
+ engines: {node: '>=18'}
425
+ cpu: [x64]
426
+ os: [openbsd]
427
+
428
+ '@esbuild/openharmony-arm64@0.25.12':
429
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
430
+ engines: {node: '>=18'}
431
+ cpu: [arm64]
432
+ os: [openharmony]
433
+
434
+ '@esbuild/openharmony-arm64@0.27.3':
435
+ resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
436
+ engines: {node: '>=18'}
437
+ cpu: [arm64]
438
+ os: [openharmony]
439
+
440
+ '@esbuild/sunos-x64@0.25.12':
441
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
442
+ engines: {node: '>=18'}
443
+ cpu: [x64]
444
+ os: [sunos]
445
+
446
+ '@esbuild/sunos-x64@0.27.3':
447
+ resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
448
+ engines: {node: '>=18'}
449
+ cpu: [x64]
450
+ os: [sunos]
451
+
452
+ '@esbuild/win32-arm64@0.25.12':
453
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
454
+ engines: {node: '>=18'}
455
+ cpu: [arm64]
456
+ os: [win32]
457
+
458
+ '@esbuild/win32-arm64@0.27.3':
459
+ resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
460
+ engines: {node: '>=18'}
461
+ cpu: [arm64]
462
+ os: [win32]
463
+
464
+ '@esbuild/win32-ia32@0.25.12':
465
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
466
+ engines: {node: '>=18'}
467
+ cpu: [ia32]
468
+ os: [win32]
469
+
470
+ '@esbuild/win32-ia32@0.27.3':
471
+ resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
472
+ engines: {node: '>=18'}
473
+ cpu: [ia32]
474
+ os: [win32]
475
+
476
+ '@esbuild/win32-x64@0.25.12':
477
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
478
+ engines: {node: '>=18'}
479
+ cpu: [x64]
480
+ os: [win32]
481
+
482
+ '@esbuild/win32-x64@0.27.3':
483
+ resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
484
+ engines: {node: '>=18'}
485
+ cpu: [x64]
486
+ os: [win32]
487
+
488
+ '@jcubic/tagger@0.6.2':
489
+ resolution: {integrity: sha512-Pcs/cx8+GXRUuAyxDLKGE+NutXVOaqixTMZhde40R8gMg+paLzdfO3LmmXZ1IYmkm8Nb3a2RyG2N8ZLxcIR3fg==}
490
+
491
+ '@jridgewell/gen-mapping@0.3.13':
492
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
493
+
494
+ '@jridgewell/remapping@2.3.5':
495
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
496
+
497
+ '@jridgewell/resolve-uri@3.1.2':
498
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
499
+ engines: {node: '>=6.0.0'}
500
+
501
+ '@jridgewell/sourcemap-codec@1.5.5':
502
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
503
+
504
+ '@jridgewell/trace-mapping@0.3.31':
505
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
506
+
507
+ '@msgpack/msgpack@3.1.3':
508
+ resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
509
+ engines: {node: '>= 18'}
510
+
511
+ '@napi-rs/canvas-android-arm64@0.1.95':
512
+ resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
513
+ engines: {node: '>= 10'}
514
+ cpu: [arm64]
515
+ os: [android]
516
+
517
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
518
+ resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
519
+ engines: {node: '>= 10'}
520
+ cpu: [arm64]
521
+ os: [darwin]
522
+
523
+ '@napi-rs/canvas-darwin-x64@0.1.95':
524
+ resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
525
+ engines: {node: '>= 10'}
526
+ cpu: [x64]
527
+ os: [darwin]
528
+
529
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
530
+ resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
531
+ engines: {node: '>= 10'}
532
+ cpu: [arm]
533
+ os: [linux]
534
+
535
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
536
+ resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
537
+ engines: {node: '>= 10'}
538
+ cpu: [arm64]
539
+ os: [linux]
540
+
541
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
542
+ resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
543
+ engines: {node: '>= 10'}
544
+ cpu: [arm64]
545
+ os: [linux]
546
+
547
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
548
+ resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
549
+ engines: {node: '>= 10'}
550
+ cpu: [riscv64]
551
+ os: [linux]
552
+
553
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
554
+ resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
555
+ engines: {node: '>= 10'}
556
+ cpu: [x64]
557
+ os: [linux]
558
+
559
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
560
+ resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
561
+ engines: {node: '>= 10'}
562
+ cpu: [x64]
563
+ os: [linux]
564
+
565
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
566
+ resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
567
+ engines: {node: '>= 10'}
568
+ cpu: [arm64]
569
+ os: [win32]
570
+
571
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
572
+ resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
573
+ engines: {node: '>= 10'}
574
+ cpu: [x64]
575
+ os: [win32]
576
+
577
+ '@napi-rs/canvas@0.1.95':
578
+ resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
579
+ engines: {node: '>= 10'}
580
+
581
+ '@parcel/watcher-android-arm64@2.5.6':
582
+ resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
583
+ engines: {node: '>= 10.0.0'}
584
+ cpu: [arm64]
585
+ os: [android]
586
+
587
+ '@parcel/watcher-darwin-arm64@2.5.6':
588
+ resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
589
+ engines: {node: '>= 10.0.0'}
590
+ cpu: [arm64]
591
+ os: [darwin]
592
+
593
+ '@parcel/watcher-darwin-x64@2.5.6':
594
+ resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
595
+ engines: {node: '>= 10.0.0'}
596
+ cpu: [x64]
597
+ os: [darwin]
598
+
599
+ '@parcel/watcher-freebsd-x64@2.5.6':
600
+ resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
601
+ engines: {node: '>= 10.0.0'}
602
+ cpu: [x64]
603
+ os: [freebsd]
604
+
605
+ '@parcel/watcher-linux-arm-glibc@2.5.6':
606
+ resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
607
+ engines: {node: '>= 10.0.0'}
608
+ cpu: [arm]
609
+ os: [linux]
610
+
611
+ '@parcel/watcher-linux-arm-musl@2.5.6':
612
+ resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
613
+ engines: {node: '>= 10.0.0'}
614
+ cpu: [arm]
615
+ os: [linux]
616
+
617
+ '@parcel/watcher-linux-arm64-glibc@2.5.6':
618
+ resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
619
+ engines: {node: '>= 10.0.0'}
620
+ cpu: [arm64]
621
+ os: [linux]
622
+
623
+ '@parcel/watcher-linux-arm64-musl@2.5.6':
624
+ resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
625
+ engines: {node: '>= 10.0.0'}
626
+ cpu: [arm64]
627
+ os: [linux]
628
+
629
+ '@parcel/watcher-linux-x64-glibc@2.5.6':
630
+ resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
631
+ engines: {node: '>= 10.0.0'}
632
+ cpu: [x64]
633
+ os: [linux]
634
+
635
+ '@parcel/watcher-linux-x64-musl@2.5.6':
636
+ resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
637
+ engines: {node: '>= 10.0.0'}
638
+ cpu: [x64]
639
+ os: [linux]
640
+
641
+ '@parcel/watcher-win32-arm64@2.5.6':
642
+ resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
643
+ engines: {node: '>= 10.0.0'}
644
+ cpu: [arm64]
645
+ os: [win32]
646
+
647
+ '@parcel/watcher-win32-ia32@2.5.6':
648
+ resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
649
+ engines: {node: '>= 10.0.0'}
650
+ cpu: [ia32]
651
+ os: [win32]
652
+
653
+ '@parcel/watcher-win32-x64@2.5.6':
654
+ resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
655
+ engines: {node: '>= 10.0.0'}
656
+ cpu: [x64]
657
+ os: [win32]
658
+
659
+ '@parcel/watcher@2.5.6':
660
+ resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
661
+ engines: {node: '>= 10.0.0'}
662
+
663
+ '@playwright/test@1.58.2':
664
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
665
+ engines: {node: '>=18'}
666
+ hasBin: true
667
+
668
+ '@polka/url@1.0.0-next.29':
669
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
670
+
671
+ '@rollup/rollup-android-arm-eabi@4.59.0':
672
+ resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
673
+ cpu: [arm]
674
+ os: [android]
675
+
676
+ '@rollup/rollup-android-arm64@4.59.0':
677
+ resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
678
+ cpu: [arm64]
679
+ os: [android]
680
+
681
+ '@rollup/rollup-darwin-arm64@4.59.0':
682
+ resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
683
+ cpu: [arm64]
684
+ os: [darwin]
685
+
686
+ '@rollup/rollup-darwin-x64@4.59.0':
687
+ resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
688
+ cpu: [x64]
689
+ os: [darwin]
690
+
691
+ '@rollup/rollup-freebsd-arm64@4.59.0':
692
+ resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
693
+ cpu: [arm64]
694
+ os: [freebsd]
695
+
696
+ '@rollup/rollup-freebsd-x64@4.59.0':
697
+ resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
698
+ cpu: [x64]
699
+ os: [freebsd]
700
+
701
+ '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
702
+ resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
703
+ cpu: [arm]
704
+ os: [linux]
705
+
706
+ '@rollup/rollup-linux-arm-musleabihf@4.59.0':
707
+ resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
708
+ cpu: [arm]
709
+ os: [linux]
710
+
711
+ '@rollup/rollup-linux-arm64-gnu@4.59.0':
712
+ resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
713
+ cpu: [arm64]
714
+ os: [linux]
715
+
716
+ '@rollup/rollup-linux-arm64-musl@4.59.0':
717
+ resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
718
+ cpu: [arm64]
719
+ os: [linux]
720
+
721
+ '@rollup/rollup-linux-loong64-gnu@4.59.0':
722
+ resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
723
+ cpu: [loong64]
724
+ os: [linux]
725
+
726
+ '@rollup/rollup-linux-loong64-musl@4.59.0':
727
+ resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
728
+ cpu: [loong64]
729
+ os: [linux]
730
+
731
+ '@rollup/rollup-linux-ppc64-gnu@4.59.0':
732
+ resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
733
+ cpu: [ppc64]
734
+ os: [linux]
735
+
736
+ '@rollup/rollup-linux-ppc64-musl@4.59.0':
737
+ resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
738
+ cpu: [ppc64]
739
+ os: [linux]
740
+
741
+ '@rollup/rollup-linux-riscv64-gnu@4.59.0':
742
+ resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
743
+ cpu: [riscv64]
744
+ os: [linux]
745
+
746
+ '@rollup/rollup-linux-riscv64-musl@4.59.0':
747
+ resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
748
+ cpu: [riscv64]
749
+ os: [linux]
750
+
751
+ '@rollup/rollup-linux-s390x-gnu@4.59.0':
752
+ resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
753
+ cpu: [s390x]
754
+ os: [linux]
755
+
756
+ '@rollup/rollup-linux-x64-gnu@4.59.0':
757
+ resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
758
+ cpu: [x64]
759
+ os: [linux]
760
+
761
+ '@rollup/rollup-linux-x64-musl@4.59.0':
762
+ resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
763
+ cpu: [x64]
764
+ os: [linux]
765
+
766
+ '@rollup/rollup-openbsd-x64@4.59.0':
767
+ resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
768
+ cpu: [x64]
769
+ os: [openbsd]
770
+
771
+ '@rollup/rollup-openharmony-arm64@4.59.0':
772
+ resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
773
+ cpu: [arm64]
774
+ os: [openharmony]
775
+
776
+ '@rollup/rollup-win32-arm64-msvc@4.59.0':
777
+ resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
778
+ cpu: [arm64]
779
+ os: [win32]
780
+
781
+ '@rollup/rollup-win32-ia32-msvc@4.59.0':
782
+ resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
783
+ cpu: [ia32]
784
+ os: [win32]
785
+
786
+ '@rollup/rollup-win32-x64-gnu@4.59.0':
787
+ resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
788
+ cpu: [x64]
789
+ os: [win32]
790
+
791
+ '@rollup/rollup-win32-x64-msvc@4.59.0':
792
+ resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
793
+ cpu: [x64]
794
+ os: [win32]
795
+
796
+ '@standard-schema/spec@1.1.0':
797
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
798
+
799
+ '@swc/core-darwin-arm64@1.15.17':
800
+ resolution: {integrity: sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w==}
801
+ engines: {node: '>=10'}
802
+ cpu: [arm64]
803
+ os: [darwin]
804
+
805
+ '@swc/core-darwin-x64@1.15.17':
806
+ resolution: {integrity: sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q==}
807
+ engines: {node: '>=10'}
808
+ cpu: [x64]
809
+ os: [darwin]
810
+
811
+ '@swc/core-linux-arm-gnueabihf@1.15.17':
812
+ resolution: {integrity: sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ==}
813
+ engines: {node: '>=10'}
814
+ cpu: [arm]
815
+ os: [linux]
816
+
817
+ '@swc/core-linux-arm64-gnu@1.15.17':
818
+ resolution: {integrity: sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A==}
819
+ engines: {node: '>=10'}
820
+ cpu: [arm64]
821
+ os: [linux]
822
+
823
+ '@swc/core-linux-arm64-musl@1.15.17':
824
+ resolution: {integrity: sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA==}
825
+ engines: {node: '>=10'}
826
+ cpu: [arm64]
827
+ os: [linux]
828
+
829
+ '@swc/core-linux-x64-gnu@1.15.17':
830
+ resolution: {integrity: sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ==}
831
+ engines: {node: '>=10'}
832
+ cpu: [x64]
833
+ os: [linux]
834
+
835
+ '@swc/core-linux-x64-musl@1.15.17':
836
+ resolution: {integrity: sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg==}
837
+ engines: {node: '>=10'}
838
+ cpu: [x64]
839
+ os: [linux]
840
+
841
+ '@swc/core-win32-arm64-msvc@1.15.17':
842
+ resolution: {integrity: sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg==}
843
+ engines: {node: '>=10'}
844
+ cpu: [arm64]
845
+ os: [win32]
846
+
847
+ '@swc/core-win32-ia32-msvc@1.15.17':
848
+ resolution: {integrity: sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ==}
849
+ engines: {node: '>=10'}
850
+ cpu: [ia32]
851
+ os: [win32]
852
+
853
+ '@swc/core-win32-x64-msvc@1.15.17':
854
+ resolution: {integrity: sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ==}
855
+ engines: {node: '>=10'}
856
+ cpu: [x64]
857
+ os: [win32]
858
+
859
+ '@swc/core@1.15.17':
860
+ resolution: {integrity: sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw==}
861
+ engines: {node: '>=10'}
862
+ peerDependencies:
863
+ '@swc/helpers': '>=0.5.17'
864
+ peerDependenciesMeta:
865
+ '@swc/helpers':
866
+ optional: true
867
+
868
+ '@swc/counter@0.1.3':
869
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
870
+
871
+ '@swc/types@0.1.25':
872
+ resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
873
+
874
+ '@tailwindcss/cli@4.2.1':
875
+ resolution: {integrity: sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==}
876
+ hasBin: true
877
+
878
+ '@tailwindcss/node@4.2.1':
879
+ resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
880
+
881
+ '@tailwindcss/oxide-android-arm64@4.2.1':
882
+ resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
883
+ engines: {node: '>= 20'}
884
+ cpu: [arm64]
885
+ os: [android]
886
+
887
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
888
+ resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
889
+ engines: {node: '>= 20'}
890
+ cpu: [arm64]
891
+ os: [darwin]
892
+
893
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
894
+ resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
895
+ engines: {node: '>= 20'}
896
+ cpu: [x64]
897
+ os: [darwin]
898
+
899
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
900
+ resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
901
+ engines: {node: '>= 20'}
902
+ cpu: [x64]
903
+ os: [freebsd]
904
+
905
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
906
+ resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
907
+ engines: {node: '>= 20'}
908
+ cpu: [arm]
909
+ os: [linux]
910
+
911
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
912
+ resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
913
+ engines: {node: '>= 20'}
914
+ cpu: [arm64]
915
+ os: [linux]
916
+
917
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
918
+ resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
919
+ engines: {node: '>= 20'}
920
+ cpu: [arm64]
921
+ os: [linux]
922
+
923
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
924
+ resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
925
+ engines: {node: '>= 20'}
926
+ cpu: [x64]
927
+ os: [linux]
928
+
929
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
930
+ resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
931
+ engines: {node: '>= 20'}
932
+ cpu: [x64]
933
+ os: [linux]
934
+
935
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
936
+ resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
937
+ engines: {node: '>=14.0.0'}
938
+ cpu: [wasm32]
939
+ bundledDependencies:
940
+ - '@napi-rs/wasm-runtime'
941
+ - '@emnapi/core'
942
+ - '@emnapi/runtime'
943
+ - '@tybys/wasm-util'
944
+ - '@emnapi/wasi-threads'
945
+ - tslib
946
+
947
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
948
+ resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
949
+ engines: {node: '>= 20'}
950
+ cpu: [arm64]
951
+ os: [win32]
952
+
953
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
954
+ resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
955
+ engines: {node: '>= 20'}
956
+ cpu: [x64]
957
+ os: [win32]
958
+
959
+ '@tailwindcss/oxide@4.2.1':
960
+ resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
961
+ engines: {node: '>= 20'}
962
+
963
+ '@testing-library/dom@10.4.1':
964
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
965
+ engines: {node: '>=18'}
966
+
967
+ '@types/aria-query@5.0.4':
968
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
969
+
970
+ '@types/chai@5.2.3':
971
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
972
+
973
+ '@types/deep-eql@4.0.2':
974
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
975
+
976
+ '@types/estree@1.0.8':
977
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
978
+
979
+ '@types/node@25.3.2':
980
+ resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==}
981
+
982
+ '@types/whatwg-mimetype@3.0.2':
983
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
984
+
985
+ '@types/ws@8.18.1':
986
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
987
+
988
+ '@vitest/coverage-v8@4.0.18':
989
+ resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
990
+ peerDependencies:
991
+ '@vitest/browser': 4.0.18
992
+ vitest: 4.0.18
993
+ peerDependenciesMeta:
994
+ '@vitest/browser':
995
+ optional: true
996
+
997
+ '@vitest/expect@4.0.18':
998
+ resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
999
+
1000
+ '@vitest/mocker@4.0.18':
1001
+ resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
1002
+ peerDependencies:
1003
+ msw: ^2.4.9
1004
+ vite: ^6.0.0 || ^7.0.0-0
1005
+ peerDependenciesMeta:
1006
+ msw:
1007
+ optional: true
1008
+ vite:
1009
+ optional: true
1010
+
1011
+ '@vitest/pretty-format@4.0.18':
1012
+ resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
1013
+
1014
+ '@vitest/runner@4.0.18':
1015
+ resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
1016
+
1017
+ '@vitest/snapshot@4.0.18':
1018
+ resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
1019
+
1020
+ '@vitest/spy@4.0.18':
1021
+ resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
1022
+
1023
+ '@vitest/ui@4.0.18':
1024
+ resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==}
1025
+ peerDependencies:
1026
+ vitest: 4.0.18
1027
+
1028
+ '@vitest/utils@4.0.18':
1029
+ resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
1030
+
1031
+ agent-base@7.1.4:
1032
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
1033
+ engines: {node: '>= 14'}
1034
+
1035
+ ansi-regex@5.0.1:
1036
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
1037
+ engines: {node: '>=8'}
1038
+
1039
+ ansi-styles@5.2.0:
1040
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
1041
+ engines: {node: '>=10'}
1042
+
1043
+ aria-query@5.3.0:
1044
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
1045
+
1046
+ assertion-error@2.0.1:
1047
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1048
+ engines: {node: '>=12'}
1049
+
1050
+ ast-v8-to-istanbul@0.3.12:
1051
+ resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
1052
+
1053
+ chai@6.2.2:
1054
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
1055
+ engines: {node: '>=18'}
1056
+
1057
+ cssstyle@4.6.0:
1058
+ resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
1059
+ engines: {node: '>=18'}
1060
+
1061
+ data-urls@5.0.0:
1062
+ resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
1063
+ engines: {node: '>=18'}
1064
+
1065
+ debug@4.4.3:
1066
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1067
+ engines: {node: '>=6.0'}
1068
+ peerDependencies:
1069
+ supports-color: '*'
1070
+ peerDependenciesMeta:
1071
+ supports-color:
1072
+ optional: true
1073
+
1074
+ decimal.js@10.6.0:
1075
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
1076
+
1077
+ dequal@2.0.3:
1078
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1079
+ engines: {node: '>=6'}
1080
+
1081
+ detect-libc@2.1.2:
1082
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1083
+ engines: {node: '>=8'}
1084
+
1085
+ dom-accessibility-api@0.5.16:
1086
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
1087
+
1088
+ enhanced-resolve@5.20.0:
1089
+ resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
1090
+ engines: {node: '>=10.13.0'}
1091
+
1092
+ entities@6.0.1:
1093
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
1094
+ engines: {node: '>=0.12'}
1095
+
1096
+ entities@7.0.1:
1097
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
1098
+ engines: {node: '>=0.12'}
1099
+
1100
+ es-module-lexer@1.7.0:
1101
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
1102
+
1103
+ esbuild@0.25.12:
1104
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
1105
+ engines: {node: '>=18'}
1106
+ hasBin: true
1107
+
1108
+ esbuild@0.27.3:
1109
+ resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
1110
+ engines: {node: '>=18'}
1111
+ hasBin: true
1112
+
1113
+ estree-walker@3.0.3:
1114
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1115
+
1116
+ expect-type@1.3.0:
1117
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
1118
+ engines: {node: '>=12.0.0'}
1119
+
1120
+ fdir@6.5.0:
1121
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1122
+ engines: {node: '>=12.0.0'}
1123
+ peerDependencies:
1124
+ picomatch: ^3 || ^4
1125
+ peerDependenciesMeta:
1126
+ picomatch:
1127
+ optional: true
1128
+
1129
+ fflate@0.8.2:
1130
+ resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
1131
+
1132
+ flatted@3.3.3:
1133
+ resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
1134
+
1135
+ fsevents@2.3.2:
1136
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
1137
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1138
+ os: [darwin]
1139
+
1140
+ fsevents@2.3.3:
1141
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1142
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1143
+ os: [darwin]
1144
+
1145
+ graceful-fs@4.2.11:
1146
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1147
+
1148
+ happy-dom@20.7.0:
1149
+ resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==}
1150
+ engines: {node: '>=20.0.0'}
1151
+
1152
+ has-flag@4.0.0:
1153
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1154
+ engines: {node: '>=8'}
1155
+
1156
+ heroicons@2.2.0:
1157
+ resolution: {integrity: sha512-yOwvztmNiBWqR946t+JdgZmyzEmnRMC2nxvHFC90bF1SUttwB6yJKYeme1JeEcBfobdOs827nCyiWBS2z/brog==}
1158
+
1159
+ html-encoding-sniffer@4.0.0:
1160
+ resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
1161
+ engines: {node: '>=18'}
1162
+
1163
+ html-escaper@2.0.2:
1164
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
1165
+
1166
+ http-proxy-agent@7.0.2:
1167
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
1168
+ engines: {node: '>= 14'}
1169
+
1170
+ https-proxy-agent@7.0.6:
1171
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
1172
+ engines: {node: '>= 14'}
1173
+
1174
+ iconv-lite@0.6.3:
1175
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
1176
+ engines: {node: '>=0.10.0'}
1177
+
1178
+ is-extglob@2.1.1:
1179
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1180
+ engines: {node: '>=0.10.0'}
1181
+
1182
+ is-glob@4.0.3:
1183
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1184
+ engines: {node: '>=0.10.0'}
1185
+
1186
+ is-potential-custom-element-name@1.0.1:
1187
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
1188
+
1189
+ istanbul-lib-coverage@3.2.2:
1190
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
1191
+ engines: {node: '>=8'}
1192
+
1193
+ istanbul-lib-report@3.0.1:
1194
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
1195
+ engines: {node: '>=10'}
1196
+
1197
+ istanbul-reports@3.2.0:
1198
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
1199
+ engines: {node: '>=8'}
1200
+
1201
+ jiti@2.6.1:
1202
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
1203
+ hasBin: true
1204
+
1205
+ js-tokens@10.0.0:
1206
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
1207
+
1208
+ js-tokens@4.0.0:
1209
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1210
+
1211
+ jsdom@26.1.0:
1212
+ resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
1213
+ engines: {node: '>=18'}
1214
+ peerDependencies:
1215
+ canvas: ^3.0.0
1216
+ peerDependenciesMeta:
1217
+ canvas:
1218
+ optional: true
1219
+
1220
+ lightningcss-android-arm64@1.31.1:
1221
+ resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
1222
+ engines: {node: '>= 12.0.0'}
1223
+ cpu: [arm64]
1224
+ os: [android]
1225
+
1226
+ lightningcss-darwin-arm64@1.31.1:
1227
+ resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
1228
+ engines: {node: '>= 12.0.0'}
1229
+ cpu: [arm64]
1230
+ os: [darwin]
1231
+
1232
+ lightningcss-darwin-x64@1.31.1:
1233
+ resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
1234
+ engines: {node: '>= 12.0.0'}
1235
+ cpu: [x64]
1236
+ os: [darwin]
1237
+
1238
+ lightningcss-freebsd-x64@1.31.1:
1239
+ resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
1240
+ engines: {node: '>= 12.0.0'}
1241
+ cpu: [x64]
1242
+ os: [freebsd]
1243
+
1244
+ lightningcss-linux-arm-gnueabihf@1.31.1:
1245
+ resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
1246
+ engines: {node: '>= 12.0.0'}
1247
+ cpu: [arm]
1248
+ os: [linux]
1249
+
1250
+ lightningcss-linux-arm64-gnu@1.31.1:
1251
+ resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
1252
+ engines: {node: '>= 12.0.0'}
1253
+ cpu: [arm64]
1254
+ os: [linux]
1255
+
1256
+ lightningcss-linux-arm64-musl@1.31.1:
1257
+ resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
1258
+ engines: {node: '>= 12.0.0'}
1259
+ cpu: [arm64]
1260
+ os: [linux]
1261
+
1262
+ lightningcss-linux-x64-gnu@1.31.1:
1263
+ resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
1264
+ engines: {node: '>= 12.0.0'}
1265
+ cpu: [x64]
1266
+ os: [linux]
1267
+
1268
+ lightningcss-linux-x64-musl@1.31.1:
1269
+ resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
1270
+ engines: {node: '>= 12.0.0'}
1271
+ cpu: [x64]
1272
+ os: [linux]
1273
+
1274
+ lightningcss-win32-arm64-msvc@1.31.1:
1275
+ resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
1276
+ engines: {node: '>= 12.0.0'}
1277
+ cpu: [arm64]
1278
+ os: [win32]
1279
+
1280
+ lightningcss-win32-x64-msvc@1.31.1:
1281
+ resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
1282
+ engines: {node: '>= 12.0.0'}
1283
+ cpu: [x64]
1284
+ os: [win32]
1285
+
1286
+ lightningcss@1.31.1:
1287
+ resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
1288
+ engines: {node: '>= 12.0.0'}
1289
+
1290
+ lru-cache@10.4.3:
1291
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
1292
+
1293
+ lz-string@1.5.0:
1294
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
1295
+ hasBin: true
1296
+
1297
+ magic-string@0.30.21:
1298
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1299
+
1300
+ magicast@0.5.2:
1301
+ resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
1302
+
1303
+ make-dir@4.0.0:
1304
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
1305
+ engines: {node: '>=10'}
1306
+
1307
+ mri@1.2.0:
1308
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
1309
+ engines: {node: '>=4'}
1310
+
1311
+ mrmime@2.0.1:
1312
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
1313
+ engines: {node: '>=10'}
1314
+
1315
+ ms@2.1.3:
1316
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1317
+
1318
+ nanoid@3.3.11:
1319
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
1320
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1321
+ hasBin: true
1322
+
1323
+ node-addon-api@7.1.1:
1324
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
1325
+
1326
+ nwsapi@2.2.23:
1327
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
1328
+
1329
+ obug@2.1.1:
1330
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
1331
+
1332
+ parse5@7.3.0:
1333
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
1334
+
1335
+ pathe@2.0.3:
1336
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
1337
+
1338
+ picocolors@1.1.1:
1339
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1340
+
1341
+ picomatch@4.0.3:
1342
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
1343
+ engines: {node: '>=12'}
1344
+
1345
+ playwright-core@1.58.2:
1346
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
1347
+ engines: {node: '>=18'}
1348
+ hasBin: true
1349
+
1350
+ playwright@1.58.2:
1351
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
1352
+ engines: {node: '>=18'}
1353
+ hasBin: true
1354
+
1355
+ postcss@8.5.6:
1356
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
1357
+ engines: {node: ^10 || ^12 || >=14}
1358
+
1359
+ pretty-format@27.5.1:
1360
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
1361
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
1362
+
1363
+ punycode@2.3.1:
1364
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1365
+ engines: {node: '>=6'}
1366
+
1367
+ react-is@17.0.2:
1368
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
1369
+
1370
+ rollup@4.59.0:
1371
+ resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
1372
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1373
+ hasBin: true
1374
+
1375
+ rrweb-cssom@0.8.0:
1376
+ resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
1377
+
1378
+ safer-buffer@2.1.2:
1379
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
1380
+
1381
+ saxes@6.0.0:
1382
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
1383
+ engines: {node: '>=v12.22.7'}
1384
+
1385
+ semver@7.7.4:
1386
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
1387
+ engines: {node: '>=10'}
1388
+ hasBin: true
1389
+
1390
+ siginfo@2.0.0:
1391
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1392
+
1393
+ sirv@3.0.2:
1394
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
1395
+ engines: {node: '>=18'}
1396
+
1397
+ source-map-js@1.2.1:
1398
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1399
+ engines: {node: '>=0.10.0'}
1400
+
1401
+ stackback@0.0.2:
1402
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
1403
+
1404
+ std-env@3.10.0:
1405
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
1406
+
1407
+ supports-color@7.2.0:
1408
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
1409
+ engines: {node: '>=8'}
1410
+
1411
+ symbol-tree@3.2.4:
1412
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
1413
+
1414
+ tailwindcss@4.2.1:
1415
+ resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
1416
+
1417
+ tapable@2.3.0:
1418
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
1419
+ engines: {node: '>=6'}
1420
+
1421
+ tinybench@2.9.0:
1422
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
1423
+
1424
+ tinyexec@1.0.2:
1425
+ resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
1426
+ engines: {node: '>=18'}
1427
+
1428
+ tinyglobby@0.2.15:
1429
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
1430
+ engines: {node: '>=12.0.0'}
1431
+
1432
+ tinyrainbow@3.0.3:
1433
+ resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
1434
+ engines: {node: '>=14.0.0'}
1435
+
1436
+ tldts-core@6.1.86:
1437
+ resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
1438
+
1439
+ tldts@6.1.86:
1440
+ resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
1441
+ hasBin: true
1442
+
1443
+ totalist@3.0.1:
1444
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
1445
+ engines: {node: '>=6'}
1446
+
1447
+ tough-cookie@5.1.2:
1448
+ resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
1449
+ engines: {node: '>=16'}
1450
+
1451
+ tr46@5.1.1:
1452
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
1453
+ engines: {node: '>=18'}
1454
+
1455
+ undici-types@7.18.2:
1456
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
1457
+
1458
+ vite@6.4.1:
1459
+ resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
1460
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
1461
+ hasBin: true
1462
+ peerDependencies:
1463
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
1464
+ jiti: '>=1.21.0'
1465
+ less: '*'
1466
+ lightningcss: ^1.21.0
1467
+ sass: '*'
1468
+ sass-embedded: '*'
1469
+ stylus: '*'
1470
+ sugarss: '*'
1471
+ terser: ^5.16.0
1472
+ tsx: ^4.8.1
1473
+ yaml: ^2.4.2
1474
+ peerDependenciesMeta:
1475
+ '@types/node':
1476
+ optional: true
1477
+ jiti:
1478
+ optional: true
1479
+ less:
1480
+ optional: true
1481
+ lightningcss:
1482
+ optional: true
1483
+ sass:
1484
+ optional: true
1485
+ sass-embedded:
1486
+ optional: true
1487
+ stylus:
1488
+ optional: true
1489
+ sugarss:
1490
+ optional: true
1491
+ terser:
1492
+ optional: true
1493
+ tsx:
1494
+ optional: true
1495
+ yaml:
1496
+ optional: true
1497
+
1498
+ vite@7.3.1:
1499
+ resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
1500
+ engines: {node: ^20.19.0 || >=22.12.0}
1501
+ hasBin: true
1502
+ peerDependencies:
1503
+ '@types/node': ^20.19.0 || >=22.12.0
1504
+ jiti: '>=1.21.0'
1505
+ less: ^4.0.0
1506
+ lightningcss: ^1.21.0
1507
+ sass: ^1.70.0
1508
+ sass-embedded: ^1.70.0
1509
+ stylus: '>=0.54.8'
1510
+ sugarss: ^5.0.0
1511
+ terser: ^5.16.0
1512
+ tsx: ^4.8.1
1513
+ yaml: ^2.4.2
1514
+ peerDependenciesMeta:
1515
+ '@types/node':
1516
+ optional: true
1517
+ jiti:
1518
+ optional: true
1519
+ less:
1520
+ optional: true
1521
+ lightningcss:
1522
+ optional: true
1523
+ sass:
1524
+ optional: true
1525
+ sass-embedded:
1526
+ optional: true
1527
+ stylus:
1528
+ optional: true
1529
+ sugarss:
1530
+ optional: true
1531
+ terser:
1532
+ optional: true
1533
+ tsx:
1534
+ optional: true
1535
+ yaml:
1536
+ optional: true
1537
+
1538
+ vitest@4.0.18:
1539
+ resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
1540
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
1541
+ hasBin: true
1542
+ peerDependencies:
1543
+ '@edge-runtime/vm': '*'
1544
+ '@opentelemetry/api': ^1.9.0
1545
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
1546
+ '@vitest/browser-playwright': 4.0.18
1547
+ '@vitest/browser-preview': 4.0.18
1548
+ '@vitest/browser-webdriverio': 4.0.18
1549
+ '@vitest/ui': 4.0.18
1550
+ happy-dom: '*'
1551
+ jsdom: '*'
1552
+ peerDependenciesMeta:
1553
+ '@edge-runtime/vm':
1554
+ optional: true
1555
+ '@opentelemetry/api':
1556
+ optional: true
1557
+ '@types/node':
1558
+ optional: true
1559
+ '@vitest/browser-playwright':
1560
+ optional: true
1561
+ '@vitest/browser-preview':
1562
+ optional: true
1563
+ '@vitest/browser-webdriverio':
1564
+ optional: true
1565
+ '@vitest/ui':
1566
+ optional: true
1567
+ happy-dom:
1568
+ optional: true
1569
+ jsdom:
1570
+ optional: true
1571
+
1572
+ w3c-xmlserializer@5.0.0:
1573
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
1574
+ engines: {node: '>=18'}
1575
+
1576
+ webidl-conversions@7.0.0:
1577
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
1578
+ engines: {node: '>=12'}
1579
+
1580
+ whatwg-encoding@3.1.1:
1581
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
1582
+ engines: {node: '>=18'}
1583
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
1584
+
1585
+ whatwg-mimetype@3.0.0:
1586
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
1587
+ engines: {node: '>=12'}
1588
+
1589
+ whatwg-mimetype@4.0.0:
1590
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
1591
+ engines: {node: '>=18'}
1592
+
1593
+ whatwg-url@14.2.0:
1594
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
1595
+ engines: {node: '>=18'}
1596
+
1597
+ why-is-node-running@2.3.0:
1598
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
1599
+ engines: {node: '>=8'}
1600
+ hasBin: true
1601
+
1602
+ ws@8.19.0:
1603
+ resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
1604
+ engines: {node: '>=10.0.0'}
1605
+ peerDependencies:
1606
+ bufferutil: ^4.0.1
1607
+ utf-8-validate: '>=5.0.2'
1608
+ peerDependenciesMeta:
1609
+ bufferutil:
1610
+ optional: true
1611
+ utf-8-validate:
1612
+ optional: true
1613
+
1614
+ xml-name-validator@5.0.0:
1615
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
1616
+ engines: {node: '>=18'}
1617
+
1618
+ xmlchars@2.2.0:
1619
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
1620
+
1621
+ snapshots:
1622
+
1623
+ '@asamuzakjp/css-color@3.2.0':
1624
+ dependencies:
1625
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
1626
+ '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
1627
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1628
+ '@csstools/css-tokenizer': 3.0.4
1629
+ lru-cache: 10.4.3
1630
+
1631
+ '@babel/code-frame@7.29.0':
1632
+ dependencies:
1633
+ '@babel/helper-validator-identifier': 7.28.5
1634
+ js-tokens: 4.0.0
1635
+ picocolors: 1.1.1
1636
+
1637
+ '@babel/helper-string-parser@7.27.1': {}
1638
+
1639
+ '@babel/helper-validator-identifier@7.28.5': {}
1640
+
1641
+ '@babel/parser@7.29.0':
1642
+ dependencies:
1643
+ '@babel/types': 7.29.0
1644
+
1645
+ '@babel/runtime@7.28.6': {}
1646
+
1647
+ '@babel/types@7.29.0':
1648
+ dependencies:
1649
+ '@babel/helper-string-parser': 7.27.1
1650
+ '@babel/helper-validator-identifier': 7.28.5
1651
+
1652
+ '@bcoe/v8-coverage@1.0.2': {}
1653
+
1654
+ '@biomejs/biome@1.9.4':
1655
+ optionalDependencies:
1656
+ '@biomejs/cli-darwin-arm64': 1.9.4
1657
+ '@biomejs/cli-darwin-x64': 1.9.4
1658
+ '@biomejs/cli-linux-arm64': 1.9.4
1659
+ '@biomejs/cli-linux-arm64-musl': 1.9.4
1660
+ '@biomejs/cli-linux-x64': 1.9.4
1661
+ '@biomejs/cli-linux-x64-musl': 1.9.4
1662
+ '@biomejs/cli-win32-arm64': 1.9.4
1663
+ '@biomejs/cli-win32-x64': 1.9.4
1664
+
1665
+ '@biomejs/cli-darwin-arm64@1.9.4':
1666
+ optional: true
1667
+
1668
+ '@biomejs/cli-darwin-x64@1.9.4':
1669
+ optional: true
1670
+
1671
+ '@biomejs/cli-linux-arm64-musl@1.9.4':
1672
+ optional: true
1673
+
1674
+ '@biomejs/cli-linux-arm64@1.9.4':
1675
+ optional: true
1676
+
1677
+ '@biomejs/cli-linux-x64-musl@1.9.4':
1678
+ optional: true
1679
+
1680
+ '@biomejs/cli-linux-x64@1.9.4':
1681
+ optional: true
1682
+
1683
+ '@biomejs/cli-win32-arm64@1.9.4':
1684
+ optional: true
1685
+
1686
+ '@biomejs/cli-win32-x64@1.9.4':
1687
+ optional: true
1688
+
1689
+ '@csstools/color-helpers@5.1.0': {}
1690
+
1691
+ '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
1692
+ dependencies:
1693
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1694
+ '@csstools/css-tokenizer': 3.0.4
1695
+
1696
+ '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
1697
+ dependencies:
1698
+ '@csstools/color-helpers': 5.1.0
1699
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
1700
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1701
+ '@csstools/css-tokenizer': 3.0.4
1702
+
1703
+ '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
1704
+ dependencies:
1705
+ '@csstools/css-tokenizer': 3.0.4
1706
+
1707
+ '@csstools/css-tokenizer@3.0.4': {}
1708
+
1709
+ '@esbuild/aix-ppc64@0.25.12':
1710
+ optional: true
1711
+
1712
+ '@esbuild/aix-ppc64@0.27.3':
1713
+ optional: true
1714
+
1715
+ '@esbuild/android-arm64@0.25.12':
1716
+ optional: true
1717
+
1718
+ '@esbuild/android-arm64@0.27.3':
1719
+ optional: true
1720
+
1721
+ '@esbuild/android-arm@0.25.12':
1722
+ optional: true
1723
+
1724
+ '@esbuild/android-arm@0.27.3':
1725
+ optional: true
1726
+
1727
+ '@esbuild/android-x64@0.25.12':
1728
+ optional: true
1729
+
1730
+ '@esbuild/android-x64@0.27.3':
1731
+ optional: true
1732
+
1733
+ '@esbuild/darwin-arm64@0.25.12':
1734
+ optional: true
1735
+
1736
+ '@esbuild/darwin-arm64@0.27.3':
1737
+ optional: true
1738
+
1739
+ '@esbuild/darwin-x64@0.25.12':
1740
+ optional: true
1741
+
1742
+ '@esbuild/darwin-x64@0.27.3':
1743
+ optional: true
1744
+
1745
+ '@esbuild/freebsd-arm64@0.25.12':
1746
+ optional: true
1747
+
1748
+ '@esbuild/freebsd-arm64@0.27.3':
1749
+ optional: true
1750
+
1751
+ '@esbuild/freebsd-x64@0.25.12':
1752
+ optional: true
1753
+
1754
+ '@esbuild/freebsd-x64@0.27.3':
1755
+ optional: true
1756
+
1757
+ '@esbuild/linux-arm64@0.25.12':
1758
+ optional: true
1759
+
1760
+ '@esbuild/linux-arm64@0.27.3':
1761
+ optional: true
1762
+
1763
+ '@esbuild/linux-arm@0.25.12':
1764
+ optional: true
1765
+
1766
+ '@esbuild/linux-arm@0.27.3':
1767
+ optional: true
1768
+
1769
+ '@esbuild/linux-ia32@0.25.12':
1770
+ optional: true
1771
+
1772
+ '@esbuild/linux-ia32@0.27.3':
1773
+ optional: true
1774
+
1775
+ '@esbuild/linux-loong64@0.25.12':
1776
+ optional: true
1777
+
1778
+ '@esbuild/linux-loong64@0.27.3':
1779
+ optional: true
1780
+
1781
+ '@esbuild/linux-mips64el@0.25.12':
1782
+ optional: true
1783
+
1784
+ '@esbuild/linux-mips64el@0.27.3':
1785
+ optional: true
1786
+
1787
+ '@esbuild/linux-ppc64@0.25.12':
1788
+ optional: true
1789
+
1790
+ '@esbuild/linux-ppc64@0.27.3':
1791
+ optional: true
1792
+
1793
+ '@esbuild/linux-riscv64@0.25.12':
1794
+ optional: true
1795
+
1796
+ '@esbuild/linux-riscv64@0.27.3':
1797
+ optional: true
1798
+
1799
+ '@esbuild/linux-s390x@0.25.12':
1800
+ optional: true
1801
+
1802
+ '@esbuild/linux-s390x@0.27.3':
1803
+ optional: true
1804
+
1805
+ '@esbuild/linux-x64@0.25.12':
1806
+ optional: true
1807
+
1808
+ '@esbuild/linux-x64@0.27.3':
1809
+ optional: true
1810
+
1811
+ '@esbuild/netbsd-arm64@0.25.12':
1812
+ optional: true
1813
+
1814
+ '@esbuild/netbsd-arm64@0.27.3':
1815
+ optional: true
1816
+
1817
+ '@esbuild/netbsd-x64@0.25.12':
1818
+ optional: true
1819
+
1820
+ '@esbuild/netbsd-x64@0.27.3':
1821
+ optional: true
1822
+
1823
+ '@esbuild/openbsd-arm64@0.25.12':
1824
+ optional: true
1825
+
1826
+ '@esbuild/openbsd-arm64@0.27.3':
1827
+ optional: true
1828
+
1829
+ '@esbuild/openbsd-x64@0.25.12':
1830
+ optional: true
1831
+
1832
+ '@esbuild/openbsd-x64@0.27.3':
1833
+ optional: true
1834
+
1835
+ '@esbuild/openharmony-arm64@0.25.12':
1836
+ optional: true
1837
+
1838
+ '@esbuild/openharmony-arm64@0.27.3':
1839
+ optional: true
1840
+
1841
+ '@esbuild/sunos-x64@0.25.12':
1842
+ optional: true
1843
+
1844
+ '@esbuild/sunos-x64@0.27.3':
1845
+ optional: true
1846
+
1847
+ '@esbuild/win32-arm64@0.25.12':
1848
+ optional: true
1849
+
1850
+ '@esbuild/win32-arm64@0.27.3':
1851
+ optional: true
1852
+
1853
+ '@esbuild/win32-ia32@0.25.12':
1854
+ optional: true
1855
+
1856
+ '@esbuild/win32-ia32@0.27.3':
1857
+ optional: true
1858
+
1859
+ '@esbuild/win32-x64@0.25.12':
1860
+ optional: true
1861
+
1862
+ '@esbuild/win32-x64@0.27.3':
1863
+ optional: true
1864
+
1865
+ '@jcubic/tagger@0.6.2': {}
1866
+
1867
+ '@jridgewell/gen-mapping@0.3.13':
1868
+ dependencies:
1869
+ '@jridgewell/sourcemap-codec': 1.5.5
1870
+ '@jridgewell/trace-mapping': 0.3.31
1871
+
1872
+ '@jridgewell/remapping@2.3.5':
1873
+ dependencies:
1874
+ '@jridgewell/gen-mapping': 0.3.13
1875
+ '@jridgewell/trace-mapping': 0.3.31
1876
+
1877
+ '@jridgewell/resolve-uri@3.1.2': {}
1878
+
1879
+ '@jridgewell/sourcemap-codec@1.5.5': {}
1880
+
1881
+ '@jridgewell/trace-mapping@0.3.31':
1882
+ dependencies:
1883
+ '@jridgewell/resolve-uri': 3.1.2
1884
+ '@jridgewell/sourcemap-codec': 1.5.5
1885
+
1886
+ '@msgpack/msgpack@3.1.3': {}
1887
+
1888
+ '@napi-rs/canvas-android-arm64@0.1.95':
1889
+ optional: true
1890
+
1891
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
1892
+ optional: true
1893
+
1894
+ '@napi-rs/canvas-darwin-x64@0.1.95':
1895
+ optional: true
1896
+
1897
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
1898
+ optional: true
1899
+
1900
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
1901
+ optional: true
1902
+
1903
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
1904
+ optional: true
1905
+
1906
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
1907
+ optional: true
1908
+
1909
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
1910
+ optional: true
1911
+
1912
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
1913
+ optional: true
1914
+
1915
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
1916
+ optional: true
1917
+
1918
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
1919
+ optional: true
1920
+
1921
+ '@napi-rs/canvas@0.1.95':
1922
+ optionalDependencies:
1923
+ '@napi-rs/canvas-android-arm64': 0.1.95
1924
+ '@napi-rs/canvas-darwin-arm64': 0.1.95
1925
+ '@napi-rs/canvas-darwin-x64': 0.1.95
1926
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
1927
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.95
1928
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.95
1929
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
1930
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.95
1931
+ '@napi-rs/canvas-linux-x64-musl': 0.1.95
1932
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.95
1933
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.95
1934
+
1935
+ '@parcel/watcher-android-arm64@2.5.6':
1936
+ optional: true
1937
+
1938
+ '@parcel/watcher-darwin-arm64@2.5.6':
1939
+ optional: true
1940
+
1941
+ '@parcel/watcher-darwin-x64@2.5.6':
1942
+ optional: true
1943
+
1944
+ '@parcel/watcher-freebsd-x64@2.5.6':
1945
+ optional: true
1946
+
1947
+ '@parcel/watcher-linux-arm-glibc@2.5.6':
1948
+ optional: true
1949
+
1950
+ '@parcel/watcher-linux-arm-musl@2.5.6':
1951
+ optional: true
1952
+
1953
+ '@parcel/watcher-linux-arm64-glibc@2.5.6':
1954
+ optional: true
1955
+
1956
+ '@parcel/watcher-linux-arm64-musl@2.5.6':
1957
+ optional: true
1958
+
1959
+ '@parcel/watcher-linux-x64-glibc@2.5.6':
1960
+ optional: true
1961
+
1962
+ '@parcel/watcher-linux-x64-musl@2.5.6':
1963
+ optional: true
1964
+
1965
+ '@parcel/watcher-win32-arm64@2.5.6':
1966
+ optional: true
1967
+
1968
+ '@parcel/watcher-win32-ia32@2.5.6':
1969
+ optional: true
1970
+
1971
+ '@parcel/watcher-win32-x64@2.5.6':
1972
+ optional: true
1973
+
1974
+ '@parcel/watcher@2.5.6':
1975
+ dependencies:
1976
+ detect-libc: 2.1.2
1977
+ is-glob: 4.0.3
1978
+ node-addon-api: 7.1.1
1979
+ picomatch: 4.0.3
1980
+ optionalDependencies:
1981
+ '@parcel/watcher-android-arm64': 2.5.6
1982
+ '@parcel/watcher-darwin-arm64': 2.5.6
1983
+ '@parcel/watcher-darwin-x64': 2.5.6
1984
+ '@parcel/watcher-freebsd-x64': 2.5.6
1985
+ '@parcel/watcher-linux-arm-glibc': 2.5.6
1986
+ '@parcel/watcher-linux-arm-musl': 2.5.6
1987
+ '@parcel/watcher-linux-arm64-glibc': 2.5.6
1988
+ '@parcel/watcher-linux-arm64-musl': 2.5.6
1989
+ '@parcel/watcher-linux-x64-glibc': 2.5.6
1990
+ '@parcel/watcher-linux-x64-musl': 2.5.6
1991
+ '@parcel/watcher-win32-arm64': 2.5.6
1992
+ '@parcel/watcher-win32-ia32': 2.5.6
1993
+ '@parcel/watcher-win32-x64': 2.5.6
1994
+
1995
+ '@playwright/test@1.58.2':
1996
+ dependencies:
1997
+ playwright: 1.58.2
1998
+
1999
+ '@polka/url@1.0.0-next.29': {}
2000
+
2001
+ '@rollup/rollup-android-arm-eabi@4.59.0':
2002
+ optional: true
2003
+
2004
+ '@rollup/rollup-android-arm64@4.59.0':
2005
+ optional: true
2006
+
2007
+ '@rollup/rollup-darwin-arm64@4.59.0':
2008
+ optional: true
2009
+
2010
+ '@rollup/rollup-darwin-x64@4.59.0':
2011
+ optional: true
2012
+
2013
+ '@rollup/rollup-freebsd-arm64@4.59.0':
2014
+ optional: true
2015
+
2016
+ '@rollup/rollup-freebsd-x64@4.59.0':
2017
+ optional: true
2018
+
2019
+ '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
2020
+ optional: true
2021
+
2022
+ '@rollup/rollup-linux-arm-musleabihf@4.59.0':
2023
+ optional: true
2024
+
2025
+ '@rollup/rollup-linux-arm64-gnu@4.59.0':
2026
+ optional: true
2027
+
2028
+ '@rollup/rollup-linux-arm64-musl@4.59.0':
2029
+ optional: true
2030
+
2031
+ '@rollup/rollup-linux-loong64-gnu@4.59.0':
2032
+ optional: true
2033
+
2034
+ '@rollup/rollup-linux-loong64-musl@4.59.0':
2035
+ optional: true
2036
+
2037
+ '@rollup/rollup-linux-ppc64-gnu@4.59.0':
2038
+ optional: true
2039
+
2040
+ '@rollup/rollup-linux-ppc64-musl@4.59.0':
2041
+ optional: true
2042
+
2043
+ '@rollup/rollup-linux-riscv64-gnu@4.59.0':
2044
+ optional: true
2045
+
2046
+ '@rollup/rollup-linux-riscv64-musl@4.59.0':
2047
+ optional: true
2048
+
2049
+ '@rollup/rollup-linux-s390x-gnu@4.59.0':
2050
+ optional: true
2051
+
2052
+ '@rollup/rollup-linux-x64-gnu@4.59.0':
2053
+ optional: true
2054
+
2055
+ '@rollup/rollup-linux-x64-musl@4.59.0':
2056
+ optional: true
2057
+
2058
+ '@rollup/rollup-openbsd-x64@4.59.0':
2059
+ optional: true
2060
+
2061
+ '@rollup/rollup-openharmony-arm64@4.59.0':
2062
+ optional: true
2063
+
2064
+ '@rollup/rollup-win32-arm64-msvc@4.59.0':
2065
+ optional: true
2066
+
2067
+ '@rollup/rollup-win32-ia32-msvc@4.59.0':
2068
+ optional: true
2069
+
2070
+ '@rollup/rollup-win32-x64-gnu@4.59.0':
2071
+ optional: true
2072
+
2073
+ '@rollup/rollup-win32-x64-msvc@4.59.0':
2074
+ optional: true
2075
+
2076
+ '@standard-schema/spec@1.1.0': {}
2077
+
2078
+ '@swc/core-darwin-arm64@1.15.17':
2079
+ optional: true
2080
+
2081
+ '@swc/core-darwin-x64@1.15.17':
2082
+ optional: true
2083
+
2084
+ '@swc/core-linux-arm-gnueabihf@1.15.17':
2085
+ optional: true
2086
+
2087
+ '@swc/core-linux-arm64-gnu@1.15.17':
2088
+ optional: true
2089
+
2090
+ '@swc/core-linux-arm64-musl@1.15.17':
2091
+ optional: true
2092
+
2093
+ '@swc/core-linux-x64-gnu@1.15.17':
2094
+ optional: true
2095
+
2096
+ '@swc/core-linux-x64-musl@1.15.17':
2097
+ optional: true
2098
+
2099
+ '@swc/core-win32-arm64-msvc@1.15.17':
2100
+ optional: true
2101
+
2102
+ '@swc/core-win32-ia32-msvc@1.15.17':
2103
+ optional: true
2104
+
2105
+ '@swc/core-win32-x64-msvc@1.15.17':
2106
+ optional: true
2107
+
2108
+ '@swc/core@1.15.17':
2109
+ dependencies:
2110
+ '@swc/counter': 0.1.3
2111
+ '@swc/types': 0.1.25
2112
+ optionalDependencies:
2113
+ '@swc/core-darwin-arm64': 1.15.17
2114
+ '@swc/core-darwin-x64': 1.15.17
2115
+ '@swc/core-linux-arm-gnueabihf': 1.15.17
2116
+ '@swc/core-linux-arm64-gnu': 1.15.17
2117
+ '@swc/core-linux-arm64-musl': 1.15.17
2118
+ '@swc/core-linux-x64-gnu': 1.15.17
2119
+ '@swc/core-linux-x64-musl': 1.15.17
2120
+ '@swc/core-win32-arm64-msvc': 1.15.17
2121
+ '@swc/core-win32-ia32-msvc': 1.15.17
2122
+ '@swc/core-win32-x64-msvc': 1.15.17
2123
+
2124
+ '@swc/counter@0.1.3': {}
2125
+
2126
+ '@swc/types@0.1.25':
2127
+ dependencies:
2128
+ '@swc/counter': 0.1.3
2129
+
2130
+ '@tailwindcss/cli@4.2.1':
2131
+ dependencies:
2132
+ '@parcel/watcher': 2.5.6
2133
+ '@tailwindcss/node': 4.2.1
2134
+ '@tailwindcss/oxide': 4.2.1
2135
+ enhanced-resolve: 5.20.0
2136
+ mri: 1.2.0
2137
+ picocolors: 1.1.1
2138
+ tailwindcss: 4.2.1
2139
+
2140
+ '@tailwindcss/node@4.2.1':
2141
+ dependencies:
2142
+ '@jridgewell/remapping': 2.3.5
2143
+ enhanced-resolve: 5.20.0
2144
+ jiti: 2.6.1
2145
+ lightningcss: 1.31.1
2146
+ magic-string: 0.30.21
2147
+ source-map-js: 1.2.1
2148
+ tailwindcss: 4.2.1
2149
+
2150
+ '@tailwindcss/oxide-android-arm64@4.2.1':
2151
+ optional: true
2152
+
2153
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
2154
+ optional: true
2155
+
2156
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
2157
+ optional: true
2158
+
2159
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
2160
+ optional: true
2161
+
2162
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
2163
+ optional: true
2164
+
2165
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
2166
+ optional: true
2167
+
2168
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
2169
+ optional: true
2170
+
2171
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
2172
+ optional: true
2173
+
2174
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
2175
+ optional: true
2176
+
2177
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
2178
+ optional: true
2179
+
2180
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
2181
+ optional: true
2182
+
2183
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
2184
+ optional: true
2185
+
2186
+ '@tailwindcss/oxide@4.2.1':
2187
+ optionalDependencies:
2188
+ '@tailwindcss/oxide-android-arm64': 4.2.1
2189
+ '@tailwindcss/oxide-darwin-arm64': 4.2.1
2190
+ '@tailwindcss/oxide-darwin-x64': 4.2.1
2191
+ '@tailwindcss/oxide-freebsd-x64': 4.2.1
2192
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
2193
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
2194
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
2195
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
2196
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.1
2197
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.1
2198
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
2199
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
2200
+
2201
+ '@testing-library/dom@10.4.1':
2202
+ dependencies:
2203
+ '@babel/code-frame': 7.29.0
2204
+ '@babel/runtime': 7.28.6
2205
+ '@types/aria-query': 5.0.4
2206
+ aria-query: 5.3.0
2207
+ dom-accessibility-api: 0.5.16
2208
+ lz-string: 1.5.0
2209
+ picocolors: 1.1.1
2210
+ pretty-format: 27.5.1
2211
+
2212
+ '@types/aria-query@5.0.4': {}
2213
+
2214
+ '@types/chai@5.2.3':
2215
+ dependencies:
2216
+ '@types/deep-eql': 4.0.2
2217
+ assertion-error: 2.0.1
2218
+
2219
+ '@types/deep-eql@4.0.2': {}
2220
+
2221
+ '@types/estree@1.0.8': {}
2222
+
2223
+ '@types/node@25.3.2':
2224
+ dependencies:
2225
+ undici-types: 7.18.2
2226
+
2227
+ '@types/whatwg-mimetype@3.0.2': {}
2228
+
2229
+ '@types/ws@8.18.1':
2230
+ dependencies:
2231
+ '@types/node': 25.3.2
2232
+
2233
+ '@vitest/coverage-v8@4.0.18(vitest@4.0.18)':
2234
+ dependencies:
2235
+ '@bcoe/v8-coverage': 1.0.2
2236
+ '@vitest/utils': 4.0.18
2237
+ ast-v8-to-istanbul: 0.3.12
2238
+ istanbul-lib-coverage: 3.2.2
2239
+ istanbul-lib-report: 3.0.1
2240
+ istanbul-reports: 3.2.0
2241
+ magicast: 0.5.2
2242
+ obug: 2.1.1
2243
+ std-env: 3.10.0
2244
+ tinyrainbow: 3.0.3
2245
+ vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
2246
+
2247
+ '@vitest/expect@4.0.18':
2248
+ dependencies:
2249
+ '@standard-schema/spec': 1.1.0
2250
+ '@types/chai': 5.2.3
2251
+ '@vitest/spy': 4.0.18
2252
+ '@vitest/utils': 4.0.18
2253
+ chai: 6.2.2
2254
+ tinyrainbow: 3.0.3
2255
+
2256
+ '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1))':
2257
+ dependencies:
2258
+ '@vitest/spy': 4.0.18
2259
+ estree-walker: 3.0.3
2260
+ magic-string: 0.30.21
2261
+ optionalDependencies:
2262
+ vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
2263
+
2264
+ '@vitest/pretty-format@4.0.18':
2265
+ dependencies:
2266
+ tinyrainbow: 3.0.3
2267
+
2268
+ '@vitest/runner@4.0.18':
2269
+ dependencies:
2270
+ '@vitest/utils': 4.0.18
2271
+ pathe: 2.0.3
2272
+
2273
+ '@vitest/snapshot@4.0.18':
2274
+ dependencies:
2275
+ '@vitest/pretty-format': 4.0.18
2276
+ magic-string: 0.30.21
2277
+ pathe: 2.0.3
2278
+
2279
+ '@vitest/spy@4.0.18': {}
2280
+
2281
+ '@vitest/ui@4.0.18(vitest@4.0.18)':
2282
+ dependencies:
2283
+ '@vitest/utils': 4.0.18
2284
+ fflate: 0.8.2
2285
+ flatted: 3.3.3
2286
+ pathe: 2.0.3
2287
+ sirv: 3.0.2
2288
+ tinyglobby: 0.2.15
2289
+ tinyrainbow: 3.0.3
2290
+ vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
2291
+
2292
+ '@vitest/utils@4.0.18':
2293
+ dependencies:
2294
+ '@vitest/pretty-format': 4.0.18
2295
+ tinyrainbow: 3.0.3
2296
+
2297
+ agent-base@7.1.4: {}
2298
+
2299
+ ansi-regex@5.0.1: {}
2300
+
2301
+ ansi-styles@5.2.0: {}
2302
+
2303
+ aria-query@5.3.0:
2304
+ dependencies:
2305
+ dequal: 2.0.3
2306
+
2307
+ assertion-error@2.0.1: {}
2308
+
2309
+ ast-v8-to-istanbul@0.3.12:
2310
+ dependencies:
2311
+ '@jridgewell/trace-mapping': 0.3.31
2312
+ estree-walker: 3.0.3
2313
+ js-tokens: 10.0.0
2314
+
2315
+ chai@6.2.2: {}
2316
+
2317
+ cssstyle@4.6.0:
2318
+ dependencies:
2319
+ '@asamuzakjp/css-color': 3.2.0
2320
+ rrweb-cssom: 0.8.0
2321
+
2322
+ data-urls@5.0.0:
2323
+ dependencies:
2324
+ whatwg-mimetype: 4.0.0
2325
+ whatwg-url: 14.2.0
2326
+
2327
+ debug@4.4.3:
2328
+ dependencies:
2329
+ ms: 2.1.3
2330
+
2331
+ decimal.js@10.6.0: {}
2332
+
2333
+ dequal@2.0.3: {}
2334
+
2335
+ detect-libc@2.1.2: {}
2336
+
2337
+ dom-accessibility-api@0.5.16: {}
2338
+
2339
+ enhanced-resolve@5.20.0:
2340
+ dependencies:
2341
+ graceful-fs: 4.2.11
2342
+ tapable: 2.3.0
2343
+
2344
+ entities@6.0.1: {}
2345
+
2346
+ entities@7.0.1: {}
2347
+
2348
+ es-module-lexer@1.7.0: {}
2349
+
2350
+ esbuild@0.25.12:
2351
+ optionalDependencies:
2352
+ '@esbuild/aix-ppc64': 0.25.12
2353
+ '@esbuild/android-arm': 0.25.12
2354
+ '@esbuild/android-arm64': 0.25.12
2355
+ '@esbuild/android-x64': 0.25.12
2356
+ '@esbuild/darwin-arm64': 0.25.12
2357
+ '@esbuild/darwin-x64': 0.25.12
2358
+ '@esbuild/freebsd-arm64': 0.25.12
2359
+ '@esbuild/freebsd-x64': 0.25.12
2360
+ '@esbuild/linux-arm': 0.25.12
2361
+ '@esbuild/linux-arm64': 0.25.12
2362
+ '@esbuild/linux-ia32': 0.25.12
2363
+ '@esbuild/linux-loong64': 0.25.12
2364
+ '@esbuild/linux-mips64el': 0.25.12
2365
+ '@esbuild/linux-ppc64': 0.25.12
2366
+ '@esbuild/linux-riscv64': 0.25.12
2367
+ '@esbuild/linux-s390x': 0.25.12
2368
+ '@esbuild/linux-x64': 0.25.12
2369
+ '@esbuild/netbsd-arm64': 0.25.12
2370
+ '@esbuild/netbsd-x64': 0.25.12
2371
+ '@esbuild/openbsd-arm64': 0.25.12
2372
+ '@esbuild/openbsd-x64': 0.25.12
2373
+ '@esbuild/openharmony-arm64': 0.25.12
2374
+ '@esbuild/sunos-x64': 0.25.12
2375
+ '@esbuild/win32-arm64': 0.25.12
2376
+ '@esbuild/win32-ia32': 0.25.12
2377
+ '@esbuild/win32-x64': 0.25.12
2378
+
2379
+ esbuild@0.27.3:
2380
+ optionalDependencies:
2381
+ '@esbuild/aix-ppc64': 0.27.3
2382
+ '@esbuild/android-arm': 0.27.3
2383
+ '@esbuild/android-arm64': 0.27.3
2384
+ '@esbuild/android-x64': 0.27.3
2385
+ '@esbuild/darwin-arm64': 0.27.3
2386
+ '@esbuild/darwin-x64': 0.27.3
2387
+ '@esbuild/freebsd-arm64': 0.27.3
2388
+ '@esbuild/freebsd-x64': 0.27.3
2389
+ '@esbuild/linux-arm': 0.27.3
2390
+ '@esbuild/linux-arm64': 0.27.3
2391
+ '@esbuild/linux-ia32': 0.27.3
2392
+ '@esbuild/linux-loong64': 0.27.3
2393
+ '@esbuild/linux-mips64el': 0.27.3
2394
+ '@esbuild/linux-ppc64': 0.27.3
2395
+ '@esbuild/linux-riscv64': 0.27.3
2396
+ '@esbuild/linux-s390x': 0.27.3
2397
+ '@esbuild/linux-x64': 0.27.3
2398
+ '@esbuild/netbsd-arm64': 0.27.3
2399
+ '@esbuild/netbsd-x64': 0.27.3
2400
+ '@esbuild/openbsd-arm64': 0.27.3
2401
+ '@esbuild/openbsd-x64': 0.27.3
2402
+ '@esbuild/openharmony-arm64': 0.27.3
2403
+ '@esbuild/sunos-x64': 0.27.3
2404
+ '@esbuild/win32-arm64': 0.27.3
2405
+ '@esbuild/win32-ia32': 0.27.3
2406
+ '@esbuild/win32-x64': 0.27.3
2407
+
2408
+ estree-walker@3.0.3:
2409
+ dependencies:
2410
+ '@types/estree': 1.0.8
2411
+
2412
+ expect-type@1.3.0: {}
2413
+
2414
+ fdir@6.5.0(picomatch@4.0.3):
2415
+ optionalDependencies:
2416
+ picomatch: 4.0.3
2417
+
2418
+ fflate@0.8.2: {}
2419
+
2420
+ flatted@3.3.3: {}
2421
+
2422
+ fsevents@2.3.2:
2423
+ optional: true
2424
+
2425
+ fsevents@2.3.3:
2426
+ optional: true
2427
+
2428
+ graceful-fs@4.2.11: {}
2429
+
2430
+ happy-dom@20.7.0:
2431
+ dependencies:
2432
+ '@types/node': 25.3.2
2433
+ '@types/whatwg-mimetype': 3.0.2
2434
+ '@types/ws': 8.18.1
2435
+ entities: 7.0.1
2436
+ whatwg-mimetype: 3.0.0
2437
+ ws: 8.19.0
2438
+ transitivePeerDependencies:
2439
+ - bufferutil
2440
+ - utf-8-validate
2441
+
2442
+ has-flag@4.0.0: {}
2443
+
2444
+ heroicons@2.2.0: {}
2445
+
2446
+ html-encoding-sniffer@4.0.0:
2447
+ dependencies:
2448
+ whatwg-encoding: 3.1.1
2449
+
2450
+ html-escaper@2.0.2: {}
2451
+
2452
+ http-proxy-agent@7.0.2:
2453
+ dependencies:
2454
+ agent-base: 7.1.4
2455
+ debug: 4.4.3
2456
+ transitivePeerDependencies:
2457
+ - supports-color
2458
+
2459
+ https-proxy-agent@7.0.6:
2460
+ dependencies:
2461
+ agent-base: 7.1.4
2462
+ debug: 4.4.3
2463
+ transitivePeerDependencies:
2464
+ - supports-color
2465
+
2466
+ iconv-lite@0.6.3:
2467
+ dependencies:
2468
+ safer-buffer: 2.1.2
2469
+
2470
+ is-extglob@2.1.1: {}
2471
+
2472
+ is-glob@4.0.3:
2473
+ dependencies:
2474
+ is-extglob: 2.1.1
2475
+
2476
+ is-potential-custom-element-name@1.0.1: {}
2477
+
2478
+ istanbul-lib-coverage@3.2.2: {}
2479
+
2480
+ istanbul-lib-report@3.0.1:
2481
+ dependencies:
2482
+ istanbul-lib-coverage: 3.2.2
2483
+ make-dir: 4.0.0
2484
+ supports-color: 7.2.0
2485
+
2486
+ istanbul-reports@3.2.0:
2487
+ dependencies:
2488
+ html-escaper: 2.0.2
2489
+ istanbul-lib-report: 3.0.1
2490
+
2491
+ jiti@2.6.1: {}
2492
+
2493
+ js-tokens@10.0.0: {}
2494
+
2495
+ js-tokens@4.0.0: {}
2496
+
2497
+ jsdom@26.1.0(@napi-rs/canvas@0.1.95):
2498
+ dependencies:
2499
+ cssstyle: 4.6.0
2500
+ data-urls: 5.0.0
2501
+ decimal.js: 10.6.0
2502
+ html-encoding-sniffer: 4.0.0
2503
+ http-proxy-agent: 7.0.2
2504
+ https-proxy-agent: 7.0.6
2505
+ is-potential-custom-element-name: 1.0.1
2506
+ nwsapi: 2.2.23
2507
+ parse5: 7.3.0
2508
+ rrweb-cssom: 0.8.0
2509
+ saxes: 6.0.0
2510
+ symbol-tree: 3.2.4
2511
+ tough-cookie: 5.1.2
2512
+ w3c-xmlserializer: 5.0.0
2513
+ webidl-conversions: 7.0.0
2514
+ whatwg-encoding: 3.1.1
2515
+ whatwg-mimetype: 4.0.0
2516
+ whatwg-url: 14.2.0
2517
+ ws: 8.19.0
2518
+ xml-name-validator: 5.0.0
2519
+ optionalDependencies:
2520
+ canvas: '@napi-rs/canvas@0.1.95'
2521
+ transitivePeerDependencies:
2522
+ - bufferutil
2523
+ - supports-color
2524
+ - utf-8-validate
2525
+
2526
+ lightningcss-android-arm64@1.31.1:
2527
+ optional: true
2528
+
2529
+ lightningcss-darwin-arm64@1.31.1:
2530
+ optional: true
2531
+
2532
+ lightningcss-darwin-x64@1.31.1:
2533
+ optional: true
2534
+
2535
+ lightningcss-freebsd-x64@1.31.1:
2536
+ optional: true
2537
+
2538
+ lightningcss-linux-arm-gnueabihf@1.31.1:
2539
+ optional: true
2540
+
2541
+ lightningcss-linux-arm64-gnu@1.31.1:
2542
+ optional: true
2543
+
2544
+ lightningcss-linux-arm64-musl@1.31.1:
2545
+ optional: true
2546
+
2547
+ lightningcss-linux-x64-gnu@1.31.1:
2548
+ optional: true
2549
+
2550
+ lightningcss-linux-x64-musl@1.31.1:
2551
+ optional: true
2552
+
2553
+ lightningcss-win32-arm64-msvc@1.31.1:
2554
+ optional: true
2555
+
2556
+ lightningcss-win32-x64-msvc@1.31.1:
2557
+ optional: true
2558
+
2559
+ lightningcss@1.31.1:
2560
+ dependencies:
2561
+ detect-libc: 2.1.2
2562
+ optionalDependencies:
2563
+ lightningcss-android-arm64: 1.31.1
2564
+ lightningcss-darwin-arm64: 1.31.1
2565
+ lightningcss-darwin-x64: 1.31.1
2566
+ lightningcss-freebsd-x64: 1.31.1
2567
+ lightningcss-linux-arm-gnueabihf: 1.31.1
2568
+ lightningcss-linux-arm64-gnu: 1.31.1
2569
+ lightningcss-linux-arm64-musl: 1.31.1
2570
+ lightningcss-linux-x64-gnu: 1.31.1
2571
+ lightningcss-linux-x64-musl: 1.31.1
2572
+ lightningcss-win32-arm64-msvc: 1.31.1
2573
+ lightningcss-win32-x64-msvc: 1.31.1
2574
+
2575
+ lru-cache@10.4.3: {}
2576
+
2577
+ lz-string@1.5.0: {}
2578
+
2579
+ magic-string@0.30.21:
2580
+ dependencies:
2581
+ '@jridgewell/sourcemap-codec': 1.5.5
2582
+
2583
+ magicast@0.5.2:
2584
+ dependencies:
2585
+ '@babel/parser': 7.29.0
2586
+ '@babel/types': 7.29.0
2587
+ source-map-js: 1.2.1
2588
+
2589
+ make-dir@4.0.0:
2590
+ dependencies:
2591
+ semver: 7.7.4
2592
+
2593
+ mri@1.2.0: {}
2594
+
2595
+ mrmime@2.0.1: {}
2596
+
2597
+ ms@2.1.3: {}
2598
+
2599
+ nanoid@3.3.11: {}
2600
+
2601
+ node-addon-api@7.1.1: {}
2602
+
2603
+ nwsapi@2.2.23: {}
2604
+
2605
+ obug@2.1.1: {}
2606
+
2607
+ parse5@7.3.0:
2608
+ dependencies:
2609
+ entities: 6.0.1
2610
+
2611
+ pathe@2.0.3: {}
2612
+
2613
+ picocolors@1.1.1: {}
2614
+
2615
+ picomatch@4.0.3: {}
2616
+
2617
+ playwright-core@1.58.2: {}
2618
+
2619
+ playwright@1.58.2:
2620
+ dependencies:
2621
+ playwright-core: 1.58.2
2622
+ optionalDependencies:
2623
+ fsevents: 2.3.2
2624
+
2625
+ postcss@8.5.6:
2626
+ dependencies:
2627
+ nanoid: 3.3.11
2628
+ picocolors: 1.1.1
2629
+ source-map-js: 1.2.1
2630
+
2631
+ pretty-format@27.5.1:
2632
+ dependencies:
2633
+ ansi-regex: 5.0.1
2634
+ ansi-styles: 5.2.0
2635
+ react-is: 17.0.2
2636
+
2637
+ punycode@2.3.1: {}
2638
+
2639
+ react-is@17.0.2: {}
2640
+
2641
+ rollup@4.59.0:
2642
+ dependencies:
2643
+ '@types/estree': 1.0.8
2644
+ optionalDependencies:
2645
+ '@rollup/rollup-android-arm-eabi': 4.59.0
2646
+ '@rollup/rollup-android-arm64': 4.59.0
2647
+ '@rollup/rollup-darwin-arm64': 4.59.0
2648
+ '@rollup/rollup-darwin-x64': 4.59.0
2649
+ '@rollup/rollup-freebsd-arm64': 4.59.0
2650
+ '@rollup/rollup-freebsd-x64': 4.59.0
2651
+ '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
2652
+ '@rollup/rollup-linux-arm-musleabihf': 4.59.0
2653
+ '@rollup/rollup-linux-arm64-gnu': 4.59.0
2654
+ '@rollup/rollup-linux-arm64-musl': 4.59.0
2655
+ '@rollup/rollup-linux-loong64-gnu': 4.59.0
2656
+ '@rollup/rollup-linux-loong64-musl': 4.59.0
2657
+ '@rollup/rollup-linux-ppc64-gnu': 4.59.0
2658
+ '@rollup/rollup-linux-ppc64-musl': 4.59.0
2659
+ '@rollup/rollup-linux-riscv64-gnu': 4.59.0
2660
+ '@rollup/rollup-linux-riscv64-musl': 4.59.0
2661
+ '@rollup/rollup-linux-s390x-gnu': 4.59.0
2662
+ '@rollup/rollup-linux-x64-gnu': 4.59.0
2663
+ '@rollup/rollup-linux-x64-musl': 4.59.0
2664
+ '@rollup/rollup-openbsd-x64': 4.59.0
2665
+ '@rollup/rollup-openharmony-arm64': 4.59.0
2666
+ '@rollup/rollup-win32-arm64-msvc': 4.59.0
2667
+ '@rollup/rollup-win32-ia32-msvc': 4.59.0
2668
+ '@rollup/rollup-win32-x64-gnu': 4.59.0
2669
+ '@rollup/rollup-win32-x64-msvc': 4.59.0
2670
+ fsevents: 2.3.3
2671
+
2672
+ rrweb-cssom@0.8.0: {}
2673
+
2674
+ safer-buffer@2.1.2: {}
2675
+
2676
+ saxes@6.0.0:
2677
+ dependencies:
2678
+ xmlchars: 2.2.0
2679
+
2680
+ semver@7.7.4: {}
2681
+
2682
+ siginfo@2.0.0: {}
2683
+
2684
+ sirv@3.0.2:
2685
+ dependencies:
2686
+ '@polka/url': 1.0.0-next.29
2687
+ mrmime: 2.0.1
2688
+ totalist: 3.0.1
2689
+
2690
+ source-map-js@1.2.1: {}
2691
+
2692
+ stackback@0.0.2: {}
2693
+
2694
+ std-env@3.10.0: {}
2695
+
2696
+ supports-color@7.2.0:
2697
+ dependencies:
2698
+ has-flag: 4.0.0
2699
+
2700
+ symbol-tree@3.2.4: {}
2701
+
2702
+ tailwindcss@4.2.1: {}
2703
+
2704
+ tapable@2.3.0: {}
2705
+
2706
+ tinybench@2.9.0: {}
2707
+
2708
+ tinyexec@1.0.2: {}
2709
+
2710
+ tinyglobby@0.2.15:
2711
+ dependencies:
2712
+ fdir: 6.5.0(picomatch@4.0.3)
2713
+ picomatch: 4.0.3
2714
+
2715
+ tinyrainbow@3.0.3: {}
2716
+
2717
+ tldts-core@6.1.86: {}
2718
+
2719
+ tldts@6.1.86:
2720
+ dependencies:
2721
+ tldts-core: 6.1.86
2722
+
2723
+ totalist@3.0.1: {}
2724
+
2725
+ tough-cookie@5.1.2:
2726
+ dependencies:
2727
+ tldts: 6.1.86
2728
+
2729
+ tr46@5.1.1:
2730
+ dependencies:
2731
+ punycode: 2.3.1
2732
+
2733
+ undici-types@7.18.2: {}
2734
+
2735
+ vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1):
2736
+ dependencies:
2737
+ esbuild: 0.25.12
2738
+ fdir: 6.5.0(picomatch@4.0.3)
2739
+ picomatch: 4.0.3
2740
+ postcss: 8.5.6
2741
+ rollup: 4.59.0
2742
+ tinyglobby: 0.2.15
2743
+ optionalDependencies:
2744
+ '@types/node': 25.3.2
2745
+ fsevents: 2.3.3
2746
+ jiti: 2.6.1
2747
+ lightningcss: 1.31.1
2748
+
2749
+ vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1):
2750
+ dependencies:
2751
+ esbuild: 0.27.3
2752
+ fdir: 6.5.0(picomatch@4.0.3)
2753
+ picomatch: 4.0.3
2754
+ postcss: 8.5.6
2755
+ rollup: 4.59.0
2756
+ tinyglobby: 0.2.15
2757
+ optionalDependencies:
2758
+ '@types/node': 25.3.2
2759
+ fsevents: 2.3.3
2760
+ jiti: 2.6.1
2761
+ lightningcss: 1.31.1
2762
+
2763
+ vitest@4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1):
2764
+ dependencies:
2765
+ '@vitest/expect': 4.0.18
2766
+ '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1))
2767
+ '@vitest/pretty-format': 4.0.18
2768
+ '@vitest/runner': 4.0.18
2769
+ '@vitest/snapshot': 4.0.18
2770
+ '@vitest/spy': 4.0.18
2771
+ '@vitest/utils': 4.0.18
2772
+ es-module-lexer: 1.7.0
2773
+ expect-type: 1.3.0
2774
+ magic-string: 0.30.21
2775
+ obug: 2.1.1
2776
+ pathe: 2.0.3
2777
+ picomatch: 4.0.3
2778
+ std-env: 3.10.0
2779
+ tinybench: 2.9.0
2780
+ tinyexec: 1.0.2
2781
+ tinyglobby: 0.2.15
2782
+ tinyrainbow: 3.0.3
2783
+ vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
2784
+ why-is-node-running: 2.3.0
2785
+ optionalDependencies:
2786
+ '@types/node': 25.3.2
2787
+ '@vitest/ui': 4.0.18(vitest@4.0.18)
2788
+ happy-dom: 20.7.0
2789
+ jsdom: 26.1.0(@napi-rs/canvas@0.1.95)
2790
+ transitivePeerDependencies:
2791
+ - jiti
2792
+ - less
2793
+ - lightningcss
2794
+ - msw
2795
+ - sass
2796
+ - sass-embedded
2797
+ - stylus
2798
+ - sugarss
2799
+ - terser
2800
+ - tsx
2801
+ - yaml
2802
+
2803
+ w3c-xmlserializer@5.0.0:
2804
+ dependencies:
2805
+ xml-name-validator: 5.0.0
2806
+
2807
+ webidl-conversions@7.0.0: {}
2808
+
2809
+ whatwg-encoding@3.1.1:
2810
+ dependencies:
2811
+ iconv-lite: 0.6.3
2812
+
2813
+ whatwg-mimetype@3.0.0: {}
2814
+
2815
+ whatwg-mimetype@4.0.0: {}
2816
+
2817
+ whatwg-url@14.2.0:
2818
+ dependencies:
2819
+ tr46: 5.1.1
2820
+ webidl-conversions: 7.0.0
2821
+
2822
+ why-is-node-running@2.3.0:
2823
+ dependencies:
2824
+ siginfo: 2.0.0
2825
+ stackback: 0.0.2
2826
+
2827
+ ws@8.19.0: {}
2828
+
2829
+ xml-name-validator@5.0.0: {}
2830
+
2831
+ xmlchars@2.2.0: {}
pyproject.toml ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.26"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "aspara"
7
+ version = "0.1.0"
8
+ description = "Blazingly fast metrics tracker for machine learning experiments"
9
+ authors = [
10
+ {name = "TOKUNAGA Hiroyuki"}
11
+ ]
12
+ license = "Apache-2.0"
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ "polars>=1.37.1",
17
+ "pydantic>=2.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ tracker = [
22
+ "uvicorn>=0.27.0",
23
+ "fastapi>=0.115.12",
24
+ "python-multipart>=0.0.22",
25
+ ]
26
+
27
+ dashboard = [
28
+ "uvicorn>=0.27.0",
29
+ "sse-starlette>=1.8.0",
30
+ "aiofiles>=23.2.0",
31
+ "watchfiles>=1.1.1",
32
+ "pystache>=0.6.8",
33
+ "fastapi>=0.115.12",
34
+ "lttb>=0.3.2",
35
+ "numpy>=1.24.0",
36
+ "msgpack>=1.0.0",
37
+ ]
38
+
39
+ remote = [
40
+ "requests>=2.31.0",
41
+ ]
42
+
43
+ tui = [
44
+ "textual>=0.47.0",
45
+ "textual-plotext>=0.2.0",
46
+ ]
47
+
48
+ all = [
49
+ "aspara[tracker]",
50
+ "aspara[dashboard]",
51
+ "aspara[remote]",
52
+ "aspara[tui]",
53
+ "aspara[docs]",
54
+ ]
55
+
56
+ docs = [
57
+ "mkdocs>=1.6.0",
58
+ "mkdocs-material>=9.6.0",
59
+ "mkdocstrings[python]>=0.24.0",
60
+ "mkdocs-autorefs>=0.5.0",
61
+ ]
62
+
63
+ [project.scripts]
64
+ aspara = "aspara.cli:main"
65
+
66
+ [dependency-groups]
67
+ dev = [
68
+ "aspara[all]",
69
+ "pytest>=8.3.4",
70
+ "pytest-asyncio>=1.0.0",
71
+ "pytest-cov>=7.0.0",
72
+ "mkdocs>=1.6.0",
73
+ "mkdocs-material>=9.6.0",
74
+ "mkdocstrings[python]>=0.24.0",
75
+ "mkdocs-autorefs>=0.5.0",
76
+ "ruff>=0.14.10",
77
+ "playwright>=1.52.0",
78
+ "pyrefly>=0.46.1",
79
+ "py-spy>=0.4.1",
80
+ "types-requests>=2.31.0",
81
+ "ty>=0.0.12",
82
+ "bandit>=1.9.3",
83
+ "httpx>=0.28.1",
84
+ "mypy>=1.19.1",
85
+ ]
86
+
87
+ [tool.ruff]
88
+ line-length = 160
89
+ indent-width = 4
90
+ target-version = "py310"
91
+ extend-exclude = [".venv", "build", "dist"]
92
+ src = ["src"]
93
+
94
+ [tool.ruff.lint]
95
+ select = ["E", "F", "W", "B", "I"]
96
+ extend-select = [
97
+ "C4", # flake8-comprehensions
98
+ "SIM", # flake8-simplify
99
+ "ERA", # eradicate
100
+ "UP", # pyupgrade
101
+ ]
102
+ extend-ignore = ["SIM108"]
103
+
104
+ [tool.pyrefly]
105
+ project-includes = [
106
+ "src/**/*.py*",
107
+ "tests/**/*.py*",
108
+ ]
109
+
110
+
111
+ [tool.ruff.format]
112
+ quote-style = "double"
113
+ indent-style = "space"
114
+ preview = true
115
+ line-ending = "auto"
116
+ docstring-code-format = true
117
+
118
+ [tool.ruff.lint.isort]
119
+ known-first-party = ["aspara"]
120
+ section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
121
+
122
+ [tool.mypy]
123
+ ignore_missing_imports = true
scripts/build-icons.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Build script to generate SVG symbol sprites from heroicons.
5
+ *
6
+ * Reads icons.config.json and generates _icons.mustache partial
7
+ * containing SVG symbols that can be referenced via <use href="#id">.
8
+ *
9
+ * Usage: node scripts/build-icons.js
10
+ */
11
+
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const ROOT_DIR = join(__dirname, '..');
18
+
19
+ const CONFIG_PATH = join(ROOT_DIR, 'icons.config.json');
20
+ const OUTPUT_PATH = join(ROOT_DIR, 'src/aspara/dashboard/templates/_icons.mustache');
21
+ const HEROICONS_PATH = join(ROOT_DIR, 'node_modules/heroicons/24');
22
+
23
+ /**
24
+ * Parse SVG file and extract attributes and inner content.
25
+ * @param {string} svgContent - Raw SVG file content
26
+ * @returns {{ attrs: Object, innerContent: string }}
27
+ */
28
+ function parseSvg(svgContent) {
29
+ // Extract attributes from the opening <svg> tag
30
+ const svgMatch = svgContent.match(/<svg([^>]*)>([\s\S]*)<\/svg>/);
31
+ if (!svgMatch) {
32
+ throw new Error('Invalid SVG format');
33
+ }
34
+
35
+ const attrsString = svgMatch[1];
36
+ const innerContent = svgMatch[2].trim();
37
+
38
+ // Parse attributes
39
+ const attrs = {};
40
+ const attrRegex = /(\S+)=["']([^"']*)["']/g;
41
+ for (const match of attrsString.matchAll(attrRegex)) {
42
+ attrs[match[1]] = match[2];
43
+ }
44
+
45
+ return { attrs, innerContent };
46
+ }
47
+
48
+ /**
49
+ * Convert SVG to symbol element.
50
+ * @param {string} svgContent - Raw SVG file content
51
+ * @param {string} id - Symbol ID
52
+ * @returns {string} Symbol element string
53
+ */
54
+ function svgToSymbol(svgContent, id) {
55
+ const { attrs, innerContent } = parseSvg(svgContent);
56
+
57
+ // Build symbol attributes (keep viewBox, fill, stroke, stroke-width)
58
+ const symbolAttrs = [`id="${id}"`];
59
+
60
+ if (attrs.viewBox) {
61
+ symbolAttrs.push(`viewBox="${attrs.viewBox}"`);
62
+ }
63
+ if (attrs.fill) {
64
+ symbolAttrs.push(`fill="${attrs.fill}"`);
65
+ }
66
+ if (attrs.stroke) {
67
+ symbolAttrs.push(`stroke="${attrs.stroke}"`);
68
+ }
69
+ if (attrs['stroke-width']) {
70
+ symbolAttrs.push(`stroke-width="${attrs['stroke-width']}"`);
71
+ }
72
+
73
+ return ` <symbol ${symbolAttrs.join(' ')}>\n ${innerContent}\n </symbol>`;
74
+ }
75
+
76
+ /**
77
+ * Main build function.
78
+ */
79
+ function build() {
80
+ console.log('Building icon sprites...');
81
+
82
+ // Read config
83
+ const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
84
+ console.log(`Found ${config.icons.length} icons in config`);
85
+
86
+ const symbols = [];
87
+
88
+ for (const icon of config.icons) {
89
+ const svgPath = join(HEROICONS_PATH, icon.style, `${icon.name}.svg`);
90
+ console.log(` Processing: ${icon.name} (${icon.style}) -> #${icon.id}`);
91
+
92
+ try {
93
+ const svgContent = readFileSync(svgPath, 'utf-8');
94
+ const symbol = svgToSymbol(svgContent, icon.id);
95
+ symbols.push(symbol);
96
+ } catch (err) {
97
+ console.error(` Error reading ${svgPath}: ${err.message}`);
98
+ process.exit(1);
99
+ }
100
+ }
101
+
102
+ // Generate output
103
+ const output = `{{!
104
+ Auto-generated icon sprites from heroicons.
105
+ Do not edit manually - run "pnpm build:icons" to regenerate.
106
+
107
+ Source: icons.config.json
108
+ }}
109
+ <svg style="display: none" aria-hidden="true">
110
+ ${symbols.join('\n')}
111
+ </svg>
112
+ `;
113
+
114
+ writeFileSync(OUTPUT_PATH, output);
115
+ console.log(`\nGenerated: ${OUTPUT_PATH}`);
116
+ console.log('Done!');
117
+ }
118
+
119
+ build();
space_README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Aspara Demo
3
+ emoji: 🌱
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Aspara Demo
12
+
13
+ Aspara — a blazingly fast metrics tracker for machine learning experiments.
14
+
15
+ This Space runs a demo dashboard with pre-generated sample data.
16
+ Browse projects, compare runs, and explore metrics to see what Aspara can do.
17
+
18
+ ## Features
19
+
20
+ - LTTB-based metric downsampling for responsive charts
21
+ - Run comparison with overlay charts
22
+ - Tag and note editing
23
+ - Real-time updates via SSE
24
+
25
+ ## Links
26
+
27
+ - [GitHub Repository](https://github.com/prednext/aspara)
src/aspara/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara - Simple metrics tracking system for machine learning experiments.
3
+
4
+ This module provides a wandb-compatible API for experiment tracking.
5
+
6
+ Examples:
7
+ >>> import aspara
8
+ >>> run = aspara.init(project="my_project", config={"lr": 0.01})
9
+ >>> aspara.log({"loss": 0.5, "accuracy": 0.95})
10
+ >>> aspara.finish()
11
+ """
12
+
13
+ from aspara.run import Config, Run, Summary, finish, init, log
14
+ from aspara.run import get_current_run as _get_current_run
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = [
18
+ "Run",
19
+ "Config",
20
+ "Summary",
21
+ "init",
22
+ "log",
23
+ "finish",
24
+ ]
25
+
26
+
27
+ # Convenience function for accessing current run's config
28
+ def config() -> Config | None:
29
+ """Get the config of the current run."""
30
+ run = _get_current_run()
31
+ return run.config if run else None
src/aspara/catalog/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Catalog module
3
+
4
+ Provides ProjectCatalog and RunCatalog for discovering and managing
5
+ projects and runs in the data directory.
6
+ """
7
+
8
+ from .project_catalog import ProjectCatalog, ProjectInfo
9
+ from .run_catalog import RunCatalog, RunInfo
10
+ from .watcher import DataDirWatcher
11
+
12
+ __all__ = [
13
+ "ProjectCatalog",
14
+ "RunCatalog",
15
+ "ProjectInfo",
16
+ "RunInfo",
17
+ "DataDirWatcher",
18
+ ]
src/aspara/catalog/project_catalog.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ProjectCatalog - Catalog for discovering and managing projects.
3
+
4
+ This module provides functionality for listing, getting, and deleting projects
5
+ in the data directory.
6
+ """
7
+
8
+ import logging
9
+ import shutil
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from aspara.exceptions import ProjectNotFoundError
17
+ from aspara.storage import ProjectMetadataStorage
18
+ from aspara.utils.validators import validate_name, validate_safe_path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ProjectInfo(BaseModel):
24
+ """Project information."""
25
+
26
+ name: str
27
+ run_count: int
28
+ last_update: datetime
29
+
30
+
31
+ class ProjectCatalog:
32
+ """Catalog for discovering and managing projects.
33
+
34
+ This class provides methods to list, get, and delete projects
35
+ in the data directory. It does not handle metrics data directly;
36
+ that responsibility belongs to MetricsStorage.
37
+ """
38
+
39
+ def __init__(self, data_dir: str | Path) -> None:
40
+ """Initialize the project catalog.
41
+
42
+ Args:
43
+ data_dir: Base directory for data storage
44
+ """
45
+ self.data_dir = Path(data_dir)
46
+
47
+ def get_projects(self) -> list[ProjectInfo]:
48
+ """List all projects in the data directory.
49
+
50
+ Uses os.scandir() for efficient directory iteration with cached stat info.
51
+
52
+ Returns:
53
+ List of ProjectInfo objects sorted by name
54
+ """
55
+ import os
56
+
57
+ projects: list[ProjectInfo] = []
58
+ if not self.data_dir.exists():
59
+ return projects
60
+
61
+ try:
62
+ # Use scandir for efficient iteration with cached stat info
63
+ with os.scandir(self.data_dir) as project_entries:
64
+ for project_entry in project_entries:
65
+ if not project_entry.is_dir():
66
+ continue
67
+
68
+ # Collect run files with stat info in single pass
69
+ run_files_mtime: list[float] = []
70
+ with os.scandir(project_entry.path) as file_entries:
71
+ for file_entry in file_entries:
72
+ if (
73
+ file_entry.name.endswith(".jsonl")
74
+ and not file_entry.name.endswith(".wal.jsonl")
75
+ and not file_entry.name.endswith(".meta.jsonl")
76
+ ):
77
+ # stat() result is cached by scandir
78
+ run_files_mtime.append(file_entry.stat().st_mtime)
79
+
80
+ run_count = len(run_files_mtime)
81
+
82
+ # Find last update time - use cached stat from scandir
83
+ if run_files_mtime:
84
+ last_update = datetime.fromtimestamp(max(run_files_mtime))
85
+ else:
86
+ last_update = datetime.fromtimestamp(project_entry.stat().st_mtime)
87
+
88
+ projects.append(
89
+ ProjectInfo(
90
+ name=project_entry.name,
91
+ run_count=run_count,
92
+ last_update=last_update,
93
+ )
94
+ )
95
+ except (OSError, PermissionError):
96
+ pass
97
+
98
+ return sorted(projects, key=lambda p: p.name)
99
+
100
+ def get(self, name: str) -> ProjectInfo:
101
+ """Get a specific project by name.
102
+
103
+ Args:
104
+ name: Project name
105
+
106
+ Returns:
107
+ ProjectInfo object
108
+
109
+ Raises:
110
+ ValueError: If project name is invalid
111
+ ProjectNotFoundError: If project does not exist
112
+ """
113
+ validate_name(name, "project name")
114
+
115
+ project_dir = self.data_dir / name
116
+ validate_safe_path(project_dir, self.data_dir)
117
+
118
+ if not project_dir.exists() or not project_dir.is_dir():
119
+ raise ProjectNotFoundError(f"Project '{name}' not found")
120
+
121
+ # Count runs
122
+ run_files = [f for f in project_dir.iterdir() if f.suffix in [".jsonl", ".db", ".wal"]]
123
+ run_count = len(run_files)
124
+
125
+ # Get last update time from run files
126
+ last_update = datetime.fromtimestamp(project_dir.stat().st_mtime)
127
+ if run_files:
128
+ last_update = max(datetime.fromtimestamp(f.stat().st_mtime) for f in run_files)
129
+
130
+ return ProjectInfo(
131
+ name=name,
132
+ run_count=run_count,
133
+ last_update=last_update,
134
+ )
135
+
136
+ def exists(self, name: str) -> bool:
137
+ """Check if a project exists.
138
+
139
+ Args:
140
+ name: Project name
141
+
142
+ Returns:
143
+ True if project exists, False otherwise
144
+ """
145
+ try:
146
+ validate_name(name, "project name")
147
+ project_dir = self.data_dir / name
148
+ validate_safe_path(project_dir, self.data_dir)
149
+ return project_dir.exists() and project_dir.is_dir()
150
+ except ValueError:
151
+ return False
152
+
153
+ def delete(self, name: str) -> None:
154
+ """Delete a project and all its runs.
155
+
156
+ Args:
157
+ name: Project name to delete
158
+
159
+ Raises:
160
+ ValueError: If project name is empty or invalid
161
+ ProjectNotFoundError: If project does not exist
162
+ PermissionError: If deletion is not permitted
163
+ """
164
+ if not name:
165
+ raise ValueError("Project name cannot be empty")
166
+
167
+ validate_name(name, "project name")
168
+
169
+ project_dir = self.data_dir / name
170
+ validate_safe_path(project_dir, self.data_dir)
171
+
172
+ if not project_dir.exists():
173
+ raise ProjectNotFoundError(f"Project '{name}' does not exist")
174
+
175
+ try:
176
+ shutil.rmtree(project_dir)
177
+ logger.info(f"Successfully deleted project: {name}")
178
+ except (PermissionError, OSError) as e:
179
+ logger.error(f"Error deleting project {name}: {type(e).__name__}")
180
+ raise
181
+
182
+ def get_metadata(self, name: str) -> dict[str, Any]:
183
+ """Get project-level metadata.json for a project.
184
+
185
+ Returns a dictionary with notes, tags, created_at, updated_at fields.
186
+ """
187
+ storage = ProjectMetadataStorage(self.data_dir, name)
188
+ return storage.get_metadata()
189
+
190
+ def update_metadata(self, name: str, metadata: dict[str, Any]) -> dict[str, Any]:
191
+ """Update project-level metadata.json for a project.
192
+
193
+ The metadata dict may contain partial fields (notes, tags).
194
+ Validation and timestamp handling is delegated to ProjectMetadataStorage.
195
+ """
196
+ storage = ProjectMetadataStorage(self.data_dir, name)
197
+ return storage.update_metadata(metadata)
198
+
199
+ def delete_metadata(self, name: str) -> bool:
200
+ """Delete project-level metadata.json for a project."""
201
+ storage = ProjectMetadataStorage(self.data_dir, name)
202
+ return storage.delete_metadata()
src/aspara/catalog/run_catalog.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RunCatalog - Catalog for discovering and managing runs within a project.
3
+
4
+ This module provides functionality for listing, getting, and deleting runs
5
+ in a project directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import shutil
15
+ from collections.abc import AsyncGenerator, Mapping
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import polars as pl
21
+ from pydantic import BaseModel, Field
22
+
23
+ from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
24
+ from aspara.models import MetricRecord, RunStatus, StatusRecord
25
+ from aspara.storage import RunMetadataStorage
26
+ from aspara.utils.timestamp import parse_to_datetime
27
+ from aspara.utils.validators import validate_name, validate_safe_path
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Threshold in seconds to consider a run as potentially failed (1 hour)
32
+ STALE_RUN_THRESHOLD_SECONDS = 3600
33
+
34
+
35
+ class RunInfo(BaseModel):
36
+ """Run information."""
37
+
38
+ name: str
39
+ run_id: str | None = None
40
+ start_time: datetime | None = None
41
+ last_update: datetime | None = None
42
+ param_count: int
43
+ artifact_count: int = 0
44
+ tags: list[str] = []
45
+ is_corrupted: bool = False
46
+ error_message: str | None = None
47
+ is_finished: bool = False
48
+ exit_code: int | None = None
49
+ status: RunStatus = Field(default=RunStatus.WIP)
50
+
51
+
52
+ def _detect_backend(data_dir: Path, project: str, run_name: str) -> str:
53
+ """Detect which storage backend a run is using.
54
+
55
+ Args:
56
+ data_dir: Base data directory
57
+ project: Project name
58
+ run_name: Run name
59
+
60
+ Returns:
61
+ "polars" if the run uses Polars backend (WAL + Parquet), "jsonl" otherwise
62
+ """
63
+ project_dir = data_dir / project
64
+
65
+ # Check for Polars backend indicators
66
+ wal_file = project_dir / f"{run_name}.wal.jsonl"
67
+ archive_dir = project_dir / f"{run_name}_archive"
68
+
69
+ # If WAL file or archive directory exists, it's a Polars backend
70
+ if wal_file.exists() or archive_dir.exists():
71
+ return "polars"
72
+
73
+ # Otherwise, it's JSONL backend
74
+ return "jsonl"
75
+
76
+
77
+ def _open_metrics_storage(
78
+ base_dir: Path | str,
79
+ project: str,
80
+ run_name: str,
81
+ ):
82
+ """Open metrics storage for an existing run.
83
+
84
+ Detects the backend type from existing files and returns
85
+ the appropriate storage instance.
86
+
87
+ Args:
88
+ base_dir: Base data directory
89
+ project: Project name
90
+ run_name: Run name
91
+
92
+ Returns:
93
+ JsonlMetricsStorage or PolarsMetricsStorage instance
94
+ """
95
+ from aspara.storage import JsonlMetricsStorage, PolarsMetricsStorage
96
+
97
+ backend = _detect_backend(Path(base_dir), project, run_name)
98
+
99
+ if backend == "polars":
100
+ return PolarsMetricsStorage(
101
+ base_dir=str(base_dir),
102
+ project_name=project,
103
+ run_name=run_name,
104
+ )
105
+ else:
106
+ return JsonlMetricsStorage(
107
+ base_dir=str(base_dir),
108
+ project_name=project,
109
+ run_name=run_name,
110
+ )
111
+
112
+
113
+ def _read_metadata_file(metadata_file: Path) -> dict:
114
+ """Read .meta.json file and return parsed data.
115
+
116
+ Args:
117
+ metadata_file: Path to the .meta.json file
118
+
119
+ Returns:
120
+ Dictionary with metadata, or empty dict if file doesn't exist or is invalid
121
+ """
122
+ if not metadata_file.exists():
123
+ return {}
124
+
125
+ try:
126
+ with open(metadata_file) as f:
127
+ return json.load(f)
128
+ except Exception as e:
129
+ logger.warning(f"Error reading metadata file {metadata_file}: {e}")
130
+ return {}
131
+
132
+
133
+ def _infer_stale_status(
134
+ status: RunStatus,
135
+ start_time: datetime | None,
136
+ is_finished: bool,
137
+ ) -> RunStatus:
138
+ """Infer MAYBE_FAILED status for old runs that were never finished.
139
+
140
+ Args:
141
+ status: Current run status
142
+ start_time: When the run started
143
+ is_finished: Whether the run has finished
144
+
145
+ Returns:
146
+ MAYBE_FAILED if run is stale, otherwise the original status
147
+ """
148
+ if status != RunStatus.WIP or not start_time or is_finished:
149
+ return status
150
+
151
+ current_time = datetime.now(timezone.utc)
152
+ age_seconds = (current_time - start_time).total_seconds()
153
+ if age_seconds > STALE_RUN_THRESHOLD_SECONDS:
154
+ return RunStatus.MAYBE_FAILED
155
+
156
+ return status
157
+
158
+
159
+ def _extract_timestamp_range(
160
+ df: pl.DataFrame,
161
+ ) -> tuple[datetime | None, datetime | None]:
162
+ """Extract start_time and last_update from DataFrame.
163
+
164
+ Args:
165
+ df: DataFrame with timestamp column
166
+
167
+ Returns:
168
+ Tuple of (start_time, last_update)
169
+ """
170
+ if len(df) == 0 or "timestamp" not in df.columns:
171
+ return (None, None)
172
+
173
+ timestamps = df.select("timestamp").to_series()
174
+ if len(timestamps) == 0:
175
+ return (None, None)
176
+
177
+ ts_min = timestamps.min()
178
+ ts_max = timestamps.max()
179
+
180
+ start_time = ts_min if isinstance(ts_min, datetime) else None
181
+ last_update = ts_max if isinstance(ts_max, datetime) else None
182
+
183
+ return (start_time, last_update)
184
+
185
+
186
+ def _check_corruption(
187
+ df: pl.DataFrame,
188
+ metadata_file_exists: bool,
189
+ ) -> tuple[bool, str | None]:
190
+ """Check if metrics data is corrupted.
191
+
192
+ Args:
193
+ df: DataFrame with metrics
194
+ metadata_file_exists: Whether metadata file exists
195
+
196
+ Returns:
197
+ Tuple of (is_corrupted, error_message)
198
+ """
199
+ if len(df) == 0 and not metadata_file_exists:
200
+ return (True, "Empty file! No data found!")
201
+ if len(df) > 0 and "timestamp" not in df.columns:
202
+ return (True, "No timestamps found! Corrupted Run!")
203
+ return (False, None)
204
+
205
+
206
+ def _map_error_to_corruption(
207
+ error: Exception,
208
+ metadata_file_exists: bool,
209
+ ) -> tuple[bool, str | None]:
210
+ """Map storage read errors to corruption status.
211
+
212
+ Args:
213
+ error: The exception that occurred
214
+ metadata_file_exists: Whether metadata file exists
215
+
216
+ Returns:
217
+ Tuple of (is_corrupted, error_message)
218
+ """
219
+ error_str = str(error).lower()
220
+
221
+ if "empty" in error_str or "empty string" in error_str:
222
+ return (True, "Empty file! No data found!")
223
+ if "expectedobjectkey" in error_str.replace(" ", "") or "invalid json" in error_str:
224
+ return (True, f"Invalid file format! Error: {error!s}")
225
+ if "timestamp" in error_str:
226
+ return (True, f"No timestamps found! Error: {error!s}")
227
+ if "step" in error_str and not metadata_file_exists:
228
+ return (True, f"Failed to read metrics: {error!s}")
229
+ if not metadata_file_exists:
230
+ return (True, f"Failed to read metrics: {error!s}")
231
+
232
+ return (False, None)
233
+
234
+
235
+ class RunCatalog:
236
+ """Catalog for discovering and managing runs within a project.
237
+
238
+ This class provides methods to list, get, delete, and watch runs.
239
+ It handles both JSONL and DuckDB storage formats.
240
+ """
241
+
242
+ def __init__(self, data_dir: str | Path) -> None:
243
+ """Initialize the run catalog.
244
+
245
+ Args:
246
+ data_dir: Base directory for data storage
247
+ """
248
+ self.data_dir = Path(data_dir)
249
+
250
+ def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
251
+ """Parse file path to extract project, run name, and file type.
252
+
253
+ Args:
254
+ file_path: Absolute path to a file (e.g., data/project/run.jsonl)
255
+
256
+ Returns:
257
+ (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
258
+ None if path doesn't match expected pattern
259
+ """
260
+ try:
261
+ relative = file_path.relative_to(self.data_dir)
262
+ except ValueError:
263
+ return None
264
+
265
+ parts = relative.parts
266
+ if len(parts) != 2:
267
+ return None
268
+
269
+ project = parts[0]
270
+ filename = parts[1]
271
+
272
+ if filename.endswith(".wal.jsonl"):
273
+ return (project, filename[:-10], "wal")
274
+ elif filename.endswith(".meta.json"):
275
+ return (project, filename[:-10], "meta")
276
+ elif filename.endswith(".jsonl"):
277
+ return (project, filename[:-6], "metrics")
278
+
279
+ return None
280
+
281
+ def _read_run_info(self, project: str, run_name: str, run_file: Path) -> RunInfo:
282
+ """Read run information from JSONL metrics file and metadata file.
283
+
284
+ Supports both JSONL and Polars backends.
285
+ Optimization: Avoids loading full DataFrame when metadata provides sufficient info.
286
+
287
+ Args:
288
+ project: Project name
289
+ run_name: Run name
290
+ run_file: Path to the JSONL metrics file
291
+
292
+ Returns:
293
+ RunInfo object with metadata from both files
294
+ """
295
+ metadata_file = run_file.parent / f"{run_name}.meta.json"
296
+
297
+ # Read metadata
298
+ metadata = _read_metadata_file(metadata_file)
299
+ run_id = metadata.get("run_id")
300
+ tags = metadata.get("tags", [])
301
+ is_finished = metadata.get("is_finished", False)
302
+ exit_code = metadata.get("exit_code")
303
+
304
+ # Read params count
305
+ params = metadata.get("params", {})
306
+ params_count = len(params) if isinstance(params, dict) else 0
307
+
308
+ # Parse status
309
+ status_value = metadata.get("status", RunStatus.WIP.value)
310
+ try:
311
+ status = RunStatus(status_value)
312
+ except ValueError:
313
+ status = RunStatus.from_is_finished_and_exit_code(is_finished, exit_code)
314
+
315
+ # Parse start_time from metadata
316
+ start_time = None
317
+ start_time_value = metadata.get("start_time")
318
+ if start_time_value is not None:
319
+ with contextlib.suppress(ValueError):
320
+ start_time = parse_to_datetime(start_time_value)
321
+
322
+ # Infer stale status
323
+ status = _infer_stale_status(status, start_time, is_finished)
324
+
325
+ # Lightweight corruption check: file exists and is not empty
326
+ is_corrupted = False
327
+ error_message = None
328
+ last_update = None
329
+
330
+ # Use file modification time as last_update
331
+ if run_file.exists():
332
+ last_update = datetime.fromtimestamp(run_file.stat().st_mtime)
333
+
334
+ if not run_file.exists() and not metadata_file.exists():
335
+ is_corrupted = True
336
+ error_message = "Run file not found"
337
+ elif run_file.exists() and run_file.stat().st_size == 0 and not metadata_file.exists():
338
+ is_corrupted = True
339
+ error_message = "Empty file! No data found!"
340
+
341
+ return RunInfo(
342
+ name=run_name,
343
+ run_id=run_id,
344
+ start_time=start_time,
345
+ last_update=last_update,
346
+ param_count=params_count,
347
+ artifact_count=0,
348
+ tags=tags,
349
+ is_corrupted=is_corrupted,
350
+ error_message=error_message,
351
+ is_finished=is_finished,
352
+ exit_code=exit_code,
353
+ status=status,
354
+ )
355
+
356
+ def get_runs(self, project: str) -> list[RunInfo]:
357
+ """List all runs in a project.
358
+
359
+ Args:
360
+ project: Project name
361
+
362
+ Returns:
363
+ List of RunInfo objects sorted by name
364
+
365
+ Raises:
366
+ ValueError: If project name is invalid
367
+ ProjectNotFoundError: If project does not exist
368
+ """
369
+ validate_name(project, "project name")
370
+
371
+ project_dir = self.data_dir / project
372
+ validate_safe_path(project_dir, self.data_dir)
373
+
374
+ if not project_dir.exists():
375
+ raise ProjectNotFoundError(f"Project '{project}' not found")
376
+
377
+ runs = []
378
+ seen_run_names: set[str] = set()
379
+
380
+ # Process .jsonl files (including .wal.jsonl for Polars backend)
381
+ for run_file in list(project_dir.glob("*.jsonl")):
382
+ # Determine run name from file
383
+ if run_file.name.endswith(".wal.jsonl"):
384
+ # Skip WAL files - they're handled by metadata
385
+ continue
386
+ else:
387
+ run_name = run_file.stem
388
+
389
+ # Skip if we've already processed this run
390
+ if run_name in seen_run_names:
391
+ continue
392
+ seen_run_names.add(run_name)
393
+
394
+ # Handle plain JSONL files
395
+ run = self._read_run_info(project, run_name, run_file)
396
+ runs.append(run)
397
+
398
+ return sorted(runs, key=lambda r: r.name)
399
+
400
+ def get(self, project: str, run: str) -> RunInfo:
401
+ """Get a specific run.
402
+
403
+ Args:
404
+ project: Project name
405
+ run: Run name
406
+
407
+ Returns:
408
+ RunInfo object
409
+
410
+ Raises:
411
+ ValueError: If project or run name is invalid
412
+ ProjectNotFoundError: If project does not exist
413
+ RunNotFoundError: If run does not exist
414
+ """
415
+ validate_name(project, "project name")
416
+ validate_name(run, "run name")
417
+
418
+ project_dir = self.data_dir / project
419
+ validate_safe_path(project_dir, self.data_dir)
420
+
421
+ if not project_dir.exists():
422
+ raise ProjectNotFoundError(f"Project '{project}' not found")
423
+
424
+ # Check for JSONL file
425
+ jsonl_file = project_dir / f"{run}.jsonl"
426
+
427
+ if jsonl_file.exists():
428
+ return self._read_run_info(project, run, jsonl_file)
429
+ else:
430
+ raise RunNotFoundError(f"Run '{run}' not found in project '{project}'")
431
+
432
+ def delete(self, project: str, run: str) -> None:
433
+ """Delete a run and its artifacts.
434
+
435
+ Args:
436
+ project: Project name
437
+ run: Run name to delete
438
+
439
+ Raises:
440
+ ValueError: If project or run name is empty or invalid
441
+ ProjectNotFoundError: If project does not exist
442
+ RunNotFoundError: If run does not exist
443
+ PermissionError: If deletion is not permitted
444
+ """
445
+ if not project:
446
+ raise ValueError("Project name cannot be empty")
447
+ if not run:
448
+ raise ValueError("Run name cannot be empty")
449
+
450
+ validate_name(project, "project name")
451
+ validate_name(run, "run name")
452
+
453
+ project_dir = self.data_dir / project
454
+ validate_safe_path(project_dir, self.data_dir)
455
+
456
+ if not project_dir.exists():
457
+ raise ProjectNotFoundError(f"Project '{project}' does not exist")
458
+
459
+ # Check for any run files
460
+ wal_file = project_dir / f"{run}.wal.jsonl"
461
+ jsonl_file = project_dir / f"{run}.jsonl"
462
+
463
+ if not wal_file.exists() and not jsonl_file.exists():
464
+ raise RunNotFoundError(f"Run '{run}' does not exist in project '{project}'")
465
+
466
+ try:
467
+ # Delete all run-related files
468
+ metadata_file = project_dir / f"{run}.meta.json"
469
+ for file_path in [wal_file, jsonl_file, metadata_file]:
470
+ if file_path.exists():
471
+ file_path.unlink()
472
+ logger.debug(f"Deleted file: {file_path}")
473
+
474
+ # Delete artifacts directory if it exists
475
+ artifacts_dir = project_dir / run / "artifacts"
476
+ run_dir = project_dir / run
477
+
478
+ if artifacts_dir.exists():
479
+ shutil.rmtree(artifacts_dir)
480
+ logger.debug(f"Deleted artifacts for {project}/{run}")
481
+
482
+ # Delete run directory if it exists and is empty
483
+ if run_dir.exists():
484
+ try:
485
+ run_dir.rmdir()
486
+ logger.debug(f"Deleted run directory for {project}/{run}")
487
+ except OSError:
488
+ pass
489
+
490
+ logger.info(f"Successfully deleted run: {project}/{run}")
491
+ except (PermissionError, OSError) as e:
492
+ logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}")
493
+ raise
494
+
495
+ def exists(self, project: str, run: str) -> bool:
496
+ """Check if a run exists.
497
+
498
+ Args:
499
+ project: Project name
500
+ run: Run name
501
+
502
+ Returns:
503
+ True if run exists, False otherwise
504
+ """
505
+ try:
506
+ validate_name(project, "project name")
507
+ validate_name(run, "run name")
508
+
509
+ project_dir = self.data_dir / project
510
+ validate_safe_path(project_dir, self.data_dir)
511
+
512
+ wal_file = project_dir / f"{run}.wal.jsonl"
513
+ jsonl_file = project_dir / f"{run}.jsonl"
514
+
515
+ return wal_file.exists() or jsonl_file.exists()
516
+ except ValueError:
517
+ return False
518
+
519
+ async def subscribe(
520
+ self,
521
+ targets: Mapping[str, list[str] | None],
522
+ since: datetime,
523
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
524
+ """Subscribe to file changes for specified targets using DataDirWatcher.
525
+
526
+ This method uses a singleton DataDirWatcher instance to minimize inotify
527
+ file descriptor usage. Multiple SSE connections share the same watcher.
528
+
529
+ Args:
530
+ targets: Dictionary mapping project names to list of run names.
531
+ If run list is None, all runs in the project are watched.
532
+ since: Filter to only yield records with timestamp >= since
533
+
534
+ Yields:
535
+ MetricRecord or StatusRecord as files are updated
536
+ """
537
+ from aspara.catalog.watcher import DataDirWatcher
538
+
539
+ watcher = await DataDirWatcher.get_instance(self.data_dir)
540
+ async for record in watcher.subscribe(targets, since):
541
+ yield record
542
+
543
+ def get_artifacts(self, project: str, run: str) -> list[dict]:
544
+ """Get artifacts for a run from metadata file.
545
+
546
+ Args:
547
+ project: Project name
548
+ run: Run name
549
+
550
+ Returns:
551
+ List of artifact dictionaries
552
+ """
553
+ validate_name(project, "project name")
554
+ validate_name(run, "run name")
555
+
556
+ # Read from metadata file
557
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
558
+ validate_safe_path(metadata_file, self.data_dir)
559
+
560
+ if metadata_file.exists():
561
+ try:
562
+ with open(metadata_file) as f:
563
+ metadata = json.load(f)
564
+ return metadata.get("artifacts", [])
565
+ except Exception as e:
566
+ logger.warning(f"Error reading artifacts from metadata file for {run}: {e}")
567
+
568
+ return []
569
+
570
+ def get_metadata(self, project: str, run: str) -> dict:
571
+ """Get run metadata from .meta.json file.
572
+
573
+ Args:
574
+ project: Project name
575
+ run: Run name
576
+
577
+ Returns:
578
+ Dictionary containing run metadata
579
+ """
580
+ storage = RunMetadataStorage(self.data_dir, project, run)
581
+ return storage.get_metadata()
582
+
583
+ def update_metadata(self, project: str, run: str, metadata: dict) -> dict:
584
+ """Update run metadata in .meta.json file.
585
+
586
+ Args:
587
+ project: Project name
588
+ run: Run name
589
+ metadata: Dictionary with fields to update (notes, tags)
590
+
591
+ Returns:
592
+ Updated complete metadata dictionary
593
+ """
594
+ storage = RunMetadataStorage(self.data_dir, project, run)
595
+ return storage.update_metadata(metadata)
596
+
597
+ def delete_metadata(self, project: str, run: str) -> bool:
598
+ """Delete run metadata file.
599
+
600
+ Args:
601
+ project: Project name
602
+ run: Run name
603
+
604
+ Returns:
605
+ True if file was deleted, False if it didn't exist
606
+ """
607
+ storage = RunMetadataStorage(self.data_dir, project, run)
608
+ return storage.delete_metadata()
609
+
610
+ def _guess_artifact_category(self, filename: str) -> str:
611
+ """Guess artifact category from file extension.
612
+
613
+ Args:
614
+ filename: Name of the artifact file
615
+
616
+ Returns:
617
+ Category string
618
+ """
619
+ ext = filename.lower().split(".")[-1] if "." in filename else ""
620
+
621
+ if ext in ["py", "js", "ts", "jsx", "tsx", "cpp", "c", "h", "java", "go", "rs", "rb", "php"]:
622
+ return "code"
623
+ if ext in ["yaml", "yml", "json", "toml", "ini", "cfg", "conf", "env"]:
624
+ return "config"
625
+ if ext in ["pt", "pth", "pkl", "pickle", "h5", "hdf5", "onnx", "pb", "tflite", "joblib"]:
626
+ return "model"
627
+ if ext in ["csv", "tsv", "parquet", "feather", "xlsx", "xls", "hdf", "npy", "npz"]:
628
+ return "data"
629
+
630
+ return "other"
631
+
632
+ def load_metrics(
633
+ self,
634
+ project: str,
635
+ run: str,
636
+ start_time: datetime | None = None,
637
+ ) -> pl.DataFrame:
638
+ """Load metrics for a run in wide format (auto-detects storage backend).
639
+
640
+ Args:
641
+ project: Project name
642
+ run: Run name
643
+ start_time: Optional start time to filter metrics from
644
+
645
+ Returns:
646
+ Polars DataFrame in wide format with columns:
647
+ - timestamp: Datetime
648
+ - step: Int64
649
+ - _<metric_name>: Float64 for each metric (underscore-prefixed)
650
+
651
+ Raises:
652
+ ValueError: If project or run name is invalid
653
+ RunNotFoundError: If run does not exist
654
+ """
655
+ validate_name(project, "project name")
656
+ validate_name(run, "run name")
657
+
658
+ # Create storage using factory function and load metrics
659
+ storage = _open_metrics_storage(self.data_dir, project, run)
660
+
661
+ try:
662
+ df = storage.load()
663
+ except Exception as e:
664
+ logger.warning(f"Failed to load metrics for {project}/{run}: {e}")
665
+ return pl.DataFrame(
666
+ schema={
667
+ "timestamp": pl.Datetime,
668
+ "step": pl.Int64,
669
+ }
670
+ )
671
+
672
+ # Apply start_time filter if specified
673
+ if start_time is not None and len(df) > 0:
674
+ df = df.filter(pl.col("timestamp") >= start_time)
675
+
676
+ return df
677
+
678
+ def get_run_config(self, project: str, run: str) -> dict[str, Any]:
679
+ """Get run config from .meta.json file.
680
+
681
+ This reads the .meta.json file which contains params, config, status, etc.
682
+ Different from get_metadata which uses ProjectMetadataStorage for notes/tags.
683
+
684
+ Args:
685
+ project: Project name
686
+ run: Run name
687
+
688
+ Returns:
689
+ Dictionary containing run config (params, config, status, etc.)
690
+ """
691
+ validate_name(project, "project name")
692
+ validate_name(run, "run name")
693
+
694
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
695
+ validate_safe_path(metadata_file, self.data_dir)
696
+
697
+ return _read_metadata_file(metadata_file)
698
+
699
+ async def get_run_config_async(self, project: str, run: str) -> dict[str, Any]:
700
+ """Get run config asynchronously using run_in_executor.
701
+
702
+ This reads the .meta.json file which contains params, config, status, etc.
703
+
704
+ Args:
705
+ project: Project name
706
+ run: Run name
707
+
708
+ Returns:
709
+ Dictionary containing run config (params, config, status, etc.)
710
+ """
711
+ loop = asyncio.get_event_loop()
712
+ return await loop.run_in_executor(None, self.get_run_config, project, run)
713
+
714
+ async def get_metadata_async(self, project: str, run: str) -> dict[str, Any]:
715
+ """Get run metadata asynchronously using run_in_executor.
716
+
717
+ Args:
718
+ project: Project name
719
+ run: Run name
720
+
721
+ Returns:
722
+ Dictionary containing run metadata (tags, notes, params, etc.)
723
+ """
724
+ loop = asyncio.get_event_loop()
725
+ return await loop.run_in_executor(None, self.get_metadata, project, run)
726
+
727
+ async def get_artifacts_async(self, project: str, run: str) -> list[dict[str, Any]]:
728
+ """Get artifacts for a run asynchronously using run_in_executor.
729
+
730
+ Args:
731
+ project: Project name
732
+ run: Run name
733
+
734
+ Returns:
735
+ List of artifact dictionaries
736
+ """
737
+ loop = asyncio.get_event_loop()
738
+ return await loop.run_in_executor(None, self.get_artifacts, project, run)
src/aspara/catalog/watcher.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DataDirWatcher - Singleton watcher for data directory.
3
+
4
+ This module provides a centralized file watcher service that uses a single
5
+ inotify watcher for the entire data directory. Multiple SSE connections
6
+ subscribe to this service, reducing inotify file descriptor usage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ import uuid
16
+ from collections.abc import AsyncGenerator, Mapping
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from watchfiles import awatch
22
+
23
+ from aspara.models import MetricRecord, RunStatus, StatusRecord
24
+ from aspara.utils.timestamp import parse_to_datetime
25
+ from aspara.utils.validators import validate_name
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class Subscription:
32
+ """Subscription to data directory changes."""
33
+
34
+ id: str
35
+ targets: Mapping[str, list[str] | None] # project -> runs (None means all runs)
36
+ since: datetime
37
+ queue: asyncio.Queue[MetricRecord | StatusRecord | None] = field(default_factory=asyncio.Queue)
38
+
39
+
40
+ class DataDirWatcher:
41
+ """Singleton watcher for data directory.
42
+
43
+ This class provides a single inotify watcher for the entire data directory,
44
+ allowing multiple SSE connections to subscribe without consuming additional
45
+ file descriptors.
46
+ """
47
+
48
+ # Size thresholds for initial read strategy
49
+ LARGE_FILE_THRESHOLD = 1 * 1024 * 1024 # 1MB
50
+ TAIL_READ_SIZE = 64 * 1024 # Read last 64KB for large files
51
+
52
+ _instance: DataDirWatcher | None = None
53
+ _lock: asyncio.Lock | None = None
54
+
55
+ def __init__(self, data_dir: Path) -> None:
56
+ """Initialize the watcher.
57
+
58
+ Note: Use get_instance() to get the singleton instance.
59
+
60
+ Args:
61
+ data_dir: Base directory for data storage
62
+ """
63
+ # Resolve to absolute path for consistent comparison with awatch paths
64
+ self.data_dir = data_dir.resolve()
65
+ self._subscriptions: dict[str, Subscription] = {}
66
+ self._task: asyncio.Task[None] | None = None
67
+ self._instance_lock = asyncio.Lock()
68
+ # Track file sizes for incremental reading
69
+ self._file_sizes: dict[Path, int] = {}
70
+ # Track run statuses for change detection
71
+ self._run_statuses: dict[tuple[str, str], str | None] = {}
72
+
73
+ @classmethod
74
+ async def get_instance(cls, data_dir: Path) -> DataDirWatcher:
75
+ """Get or create singleton instance.
76
+
77
+ Args:
78
+ data_dir: Base directory for data storage
79
+
80
+ Returns:
81
+ DataDirWatcher singleton instance
82
+ """
83
+ if cls._lock is None:
84
+ cls._lock = asyncio.Lock()
85
+
86
+ async with cls._lock:
87
+ if cls._instance is None:
88
+ cls._instance = cls(data_dir)
89
+ logger.info(f"[Watcher] Created singleton DataDirWatcher for {data_dir}")
90
+ return cls._instance
91
+
92
+ @classmethod
93
+ def reset_instance(cls) -> None:
94
+ """Reset the singleton instance. Used for testing."""
95
+ cls._instance = None
96
+ cls._lock = None
97
+
98
+ def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
99
+ """Parse file path to extract project, run name, and file type.
100
+
101
+ Args:
102
+ file_path: Absolute path to a file
103
+
104
+ Returns:
105
+ (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
106
+ None if path doesn't match expected pattern
107
+ """
108
+ try:
109
+ relative = file_path.relative_to(self.data_dir)
110
+ except ValueError:
111
+ return None
112
+
113
+ parts = relative.parts
114
+ if len(parts) != 2:
115
+ return None
116
+
117
+ project = parts[0]
118
+ filename = parts[1]
119
+
120
+ if filename.endswith(".wal.jsonl"):
121
+ return (project, filename[:-10], "wal")
122
+ elif filename.endswith(".meta.json"):
123
+ return (project, filename[:-10], "meta")
124
+ elif filename.endswith(".jsonl"):
125
+ return (project, filename[:-6], "metrics")
126
+
127
+ return None
128
+
129
+ def _parse_metric_line(self, line: str, project: str, run: str, since: datetime) -> MetricRecord | None:
130
+ """Parse a JSONL line and return MetricRecord if it passes the since filter.
131
+
132
+ Args:
133
+ line: A single line from a JSONL file
134
+ project: Project name
135
+ run: Run name
136
+ since: Filter timestamp - only records with timestamp >= since are returned
137
+
138
+ Returns:
139
+ MetricRecord if parsing succeeds and passes filter, None otherwise
140
+ """
141
+ if not line.strip():
142
+ return None
143
+ try:
144
+ entry = json.loads(line)
145
+ ts_value = entry.get("timestamp")
146
+ record_ts = None
147
+ if ts_value is not None:
148
+ with contextlib.suppress(ValueError):
149
+ record_ts = parse_to_datetime(ts_value)
150
+ if record_ts is None or record_ts >= since:
151
+ entry["run"] = run
152
+ entry["project"] = project
153
+ return MetricRecord(**entry)
154
+ except Exception as e:
155
+ logger.debug(f"[Watcher] Error parsing line: {e}")
156
+ return None
157
+
158
+ def _read_file_with_strategy(self, file_path: Path) -> tuple[str, int]:
159
+ """Read file content with size-based strategy.
160
+
161
+ For large files, only the tail portion is read to improve initial load time.
162
+
163
+ Args:
164
+ file_path: Path to the file to read
165
+
166
+ Returns:
167
+ Tuple of (content, end_position) where end_position is the file position after reading
168
+ """
169
+ file_size = file_path.stat().st_size
170
+
171
+ if file_size < self.LARGE_FILE_THRESHOLD:
172
+ with open(file_path) as f:
173
+ content = f.read()
174
+ return content, f.tell()
175
+
176
+ # Large file: read tail only
177
+ logger.debug(f"[Watcher] Large file ({file_size} bytes), reading tail: {file_path}")
178
+ with open(file_path) as f:
179
+ read_start = max(0, file_size - self.TAIL_READ_SIZE)
180
+ f.seek(read_start)
181
+ content = f.read()
182
+ end_pos = f.tell()
183
+
184
+ # Skip partial first line if we didn't start at beginning
185
+ if read_start > 0:
186
+ first_newline = content.find("\n")
187
+ if first_newline != -1:
188
+ content = content[first_newline + 1 :]
189
+
190
+ return content, end_pos
191
+
192
+ def _init_run_status(self, project: str, run: str, meta_file: Path) -> None:
193
+ """Initialize run status tracking from meta file.
194
+
195
+ Args:
196
+ project: Project name
197
+ run: Run name
198
+ meta_file: Path to the metadata file
199
+ """
200
+ key = (project, run)
201
+ if meta_file.exists():
202
+ try:
203
+ with open(meta_file) as f:
204
+ meta = json.load(f)
205
+ self._run_statuses[key] = meta.get("status")
206
+ except Exception:
207
+ self._run_statuses[key] = None
208
+ else:
209
+ self._run_statuses[key] = None
210
+
211
+ def _matches_targets(self, targets: Mapping[str, list[str] | None], project: str, run: str) -> bool:
212
+ """Check if a project/run matches the subscription targets.
213
+
214
+ Args:
215
+ targets: Subscription targets
216
+ project: Project name
217
+ run: Run name
218
+
219
+ Returns:
220
+ True if the project/run matches the targets
221
+ """
222
+ if project not in targets:
223
+ return False
224
+
225
+ run_list = targets[project]
226
+ if run_list is None:
227
+ # None means watch all runs in the project
228
+ return True
229
+
230
+ return run in run_list
231
+
232
+ async def _read_initial_data(
233
+ self,
234
+ targets: Mapping[str, list[str] | None],
235
+ since: datetime,
236
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
237
+ """Read initial data from existing files.
238
+
239
+ Args:
240
+ targets: Dictionary mapping project names to run lists
241
+ since: Filter to only yield records with timestamp >= since
242
+
243
+ Yields:
244
+ MetricRecord objects from existing files
245
+ """
246
+ for project, run_names in targets.items():
247
+ try:
248
+ validate_name(project, "project name")
249
+ except ValueError as e:
250
+ logger.warning(f"[Watcher] Invalid project name {project}: {e}")
251
+ continue
252
+
253
+ project_dir = self.data_dir / project
254
+ if not project_dir.exists():
255
+ logger.warning(f"[Watcher] Project directory does not exist: {project_dir}")
256
+ continue
257
+
258
+ # If run_names is None, discover all runs
259
+ if run_names is None:
260
+ actual_runs = []
261
+ for f in project_dir.glob("*.jsonl"):
262
+ if f.name.endswith(".wal.jsonl"):
263
+ continue
264
+ # Skip symlinks to prevent symlink-based attacks
265
+ if f.is_symlink():
266
+ logger.warning(f"[Watcher] Skipping symlink: {f}")
267
+ continue
268
+ actual_runs.append(f.stem)
269
+ run_names = actual_runs
270
+
271
+ for run in run_names:
272
+ # Check which files exist for this run
273
+ wal_file = project_dir / f"{run}.wal.jsonl"
274
+ jsonl_file = project_dir / f"{run}.jsonl"
275
+ meta_file = project_dir / f"{run}.meta.json"
276
+
277
+ # Initialize status tracking
278
+ self._init_run_status(project, run, meta_file)
279
+
280
+ # Read metrics files
281
+ for file_path in [wal_file, jsonl_file]:
282
+ if not file_path.exists():
283
+ continue
284
+
285
+ resolved = file_path.resolve()
286
+
287
+ try:
288
+ content, end_pos = self._read_file_with_strategy(resolved)
289
+ self._file_sizes[resolved] = end_pos
290
+
291
+ for line in content.splitlines():
292
+ record = self._parse_metric_line(line, project, run, since)
293
+ if record is not None:
294
+ yield record
295
+ except Exception as e:
296
+ logger.warning(f"[Watcher] Error reading {resolved}: {e}")
297
+ if resolved.exists():
298
+ self._file_sizes[resolved] = resolved.stat().st_size
299
+
300
+ # Record meta file size
301
+ if meta_file.exists():
302
+ self._file_sizes[meta_file.resolve()] = meta_file.stat().st_size
303
+
304
+ async def _dispatch_loop(self) -> None:
305
+ """Main loop: watch data_dir and dispatch to subscribers."""
306
+ logger.info(f"[Watcher] Starting dispatch loop for {self.data_dir}")
307
+ watcher = None
308
+
309
+ try:
310
+ watcher = awatch(str(self.data_dir))
311
+ loop_count = 0
312
+ async for changes in watcher:
313
+ loop_count += 1
314
+ if loop_count % 10000 == 0:
315
+ logger.warning(f"[Watcher] Loop count: {loop_count}, changes: {len(changes)}")
316
+ logger.debug(f"[Watcher] Received {len(changes)} change(s)")
317
+
318
+ for _change_type, changed_path_str in changes:
319
+ changed_path = Path(changed_path_str).resolve()
320
+
321
+ # Parse file path to get project/run/type
322
+ parsed = self._parse_file_path(changed_path)
323
+ if parsed is None:
324
+ continue
325
+
326
+ project, run, file_type = parsed
327
+ logger.debug(f"[Watcher] File change: {changed_path} (project={project}, run={run}, type={file_type})")
328
+
329
+ # Dispatch to matching subscribers
330
+ async with self._instance_lock:
331
+ for sub in self._subscriptions.values():
332
+ if not self._matches_targets(sub.targets, project, run):
333
+ continue
334
+
335
+ try:
336
+ if file_type == "meta":
337
+ # Handle metadata/status update
338
+ status_record = await self._process_meta_change(changed_path, project, run)
339
+ if status_record:
340
+ await sub.queue.put(status_record)
341
+ else:
342
+ # Handle metrics update
343
+ metric_records = await self._process_metrics_change(changed_path, project, run, sub.since)
344
+ for metric_record in metric_records:
345
+ await sub.queue.put(metric_record)
346
+ except Exception as e:
347
+ logger.error(f"[Watcher] Error dispatching to subscription: {e}")
348
+
349
+ except asyncio.CancelledError:
350
+ logger.info("[Watcher] Dispatch loop cancelled")
351
+ raise
352
+ except Exception as e:
353
+ logger.error(f"[Watcher] Error in dispatch loop: {e}")
354
+ finally:
355
+ if watcher is not None:
356
+ logger.info("[Watcher] Closing awatch instance")
357
+ try:
358
+ await asyncio.wait_for(watcher.aclose(), timeout=2.0)
359
+ except asyncio.TimeoutError:
360
+ logger.warning("[Watcher] Timeout closing awatch instance")
361
+ except Exception as e:
362
+ logger.error(f"[Watcher] Error closing watcher: {e}")
363
+
364
+ async def _process_meta_change(self, file_path: Path, project: str, run: str) -> StatusRecord | None:
365
+ """Process a metadata file change.
366
+
367
+ Args:
368
+ file_path: Path to the metadata file
369
+ project: Project name
370
+ run: Run name
371
+
372
+ Returns:
373
+ StatusRecord if status changed, None otherwise
374
+ """
375
+ try:
376
+ with open(file_path) as f:
377
+ meta = json.load(f)
378
+ new_status = meta.get("status")
379
+
380
+ key = (project, run)
381
+ if new_status != self._run_statuses.get(key):
382
+ logger.info(f"[Watcher] Status change for {project}/{run}: {self._run_statuses.get(key)} -> {new_status}")
383
+ self._run_statuses[key] = new_status
384
+
385
+ return StatusRecord(
386
+ run=run,
387
+ project=project,
388
+ status=new_status or RunStatus.WIP.value,
389
+ is_finished=meta.get("is_finished", False),
390
+ exit_code=meta.get("exit_code"),
391
+ )
392
+ except Exception as e:
393
+ logger.error(f"[Watcher] Error reading metadata file {file_path}: {e}")
394
+
395
+ return None
396
+
397
+ async def _process_metrics_change(self, file_path: Path, project: str, run: str, since: datetime) -> list[MetricRecord]:
398
+ """Process a metrics file change.
399
+
400
+ Args:
401
+ file_path: Path to the metrics file
402
+ project: Project name
403
+ run: Run name
404
+ since: Filter timestamp
405
+
406
+ Returns:
407
+ List of MetricRecord objects
408
+ """
409
+ records: list[MetricRecord] = []
410
+
411
+ try:
412
+ current_size = self._file_sizes.get(file_path, 0)
413
+ with open(file_path) as f:
414
+ f.seek(current_size)
415
+ new_content = f.read()
416
+ self._file_sizes[file_path] = f.tell()
417
+
418
+ for line in new_content.splitlines():
419
+ record = self._parse_metric_line(line, project, run, since)
420
+ if record is not None:
421
+ records.append(record)
422
+
423
+ except Exception as e:
424
+ logger.error(f"[Watcher] Error processing metrics file {file_path}: {e}")
425
+
426
+ return records
427
+
428
+ async def subscribe(
429
+ self,
430
+ targets: Mapping[str, list[str] | None],
431
+ since: datetime,
432
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
433
+ """Subscribe to file changes for specified targets.
434
+
435
+ Args:
436
+ targets: Dictionary mapping project names to list of run names.
437
+ If run list is None, all runs in the project are watched.
438
+ since: Filter to only yield records with timestamp >= since
439
+
440
+ Yields:
441
+ MetricRecord or StatusRecord as files are updated
442
+ """
443
+ # Ensure since is timezone-aware
444
+ if since.tzinfo is None:
445
+ since = since.replace(tzinfo=timezone.utc)
446
+
447
+ subscription_id = str(uuid.uuid4())
448
+ queue: asyncio.Queue[MetricRecord | StatusRecord | None] = asyncio.Queue()
449
+
450
+ subscription = Subscription(
451
+ id=subscription_id,
452
+ targets=targets,
453
+ since=since,
454
+ queue=queue,
455
+ )
456
+
457
+ logger.info(f"[Watcher] New subscription {subscription_id} for targets={targets}")
458
+
459
+ async with self._instance_lock:
460
+ self._subscriptions[subscription_id] = subscription
461
+ # Start watcher task if not running
462
+ if self._task is None or self._task.done():
463
+ logger.info("[Watcher] Starting dispatch task")
464
+ self._task = asyncio.create_task(self._dispatch_loop())
465
+
466
+ try:
467
+ # Yield initial data (existing records >= since)
468
+ async for record in self._read_initial_data(targets, since):
469
+ yield record
470
+
471
+ # Yield updates from queue
472
+ while True:
473
+ queued_record: MetricRecord | StatusRecord | None = await queue.get()
474
+ if queued_record is None: # Sentinel for unsubscribe
475
+ break
476
+ yield queued_record
477
+ finally:
478
+ await self._unsubscribe(subscription_id)
479
+
480
+ async def _unsubscribe(self, subscription_id: str) -> None:
481
+ """Unsubscribe from file changes.
482
+
483
+ Args:
484
+ subscription_id: Subscription ID to remove
485
+ """
486
+ logger.info(f"[Watcher] Unsubscribing {subscription_id}")
487
+
488
+ async with self._instance_lock:
489
+ if subscription_id in self._subscriptions:
490
+ del self._subscriptions[subscription_id]
491
+
492
+ # Stop watcher task if no more subscribers
493
+ if not self._subscriptions and self._task is not None:
494
+ logger.info("[Watcher] No more subscribers, stopping dispatch task")
495
+ self._task.cancel()
496
+ try:
497
+ await asyncio.wait_for(self._task, timeout=2.0)
498
+ except asyncio.TimeoutError:
499
+ logger.warning("[Watcher] Timeout waiting for dispatch task to finish")
500
+ except asyncio.CancelledError:
501
+ pass
502
+ self._task = None
503
+
504
+ @property
505
+ def subscription_count(self) -> int:
506
+ """Get the number of active subscriptions."""
507
+ return len(self._subscriptions)
src/aspara/cli.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aspara CLI tool
4
+
5
+ Command line interface for starting dashboard and tracker API
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import socket
13
+ import sys
14
+
15
+ import uvicorn
16
+
17
+ from aspara.config import get_data_dir, get_storage_backend
18
+
19
+
20
+ def parse_serve_components(components: list[str]) -> tuple[bool, bool]:
21
+ """
22
+ Parse and validate component list for serve command
23
+
24
+ Args:
25
+ components: List of component names
26
+
27
+ Returns:
28
+ Tuple of (enable_dashboard, enable_tracker)
29
+
30
+ Raises:
31
+ ValueError: If invalid component name is provided
32
+ """
33
+ valid_components = {"dashboard", "tracker", "together"}
34
+
35
+ # Default: dashboard only
36
+ if not components:
37
+ return (True, False)
38
+
39
+ # Normalize and validate
40
+ normalized = [c.lower() for c in components]
41
+ for comp in normalized:
42
+ if comp not in valid_components:
43
+ raise ValueError(f"Invalid component: {comp}. Valid options are: dashboard, tracker, together")
44
+
45
+ # Handle 'together' keyword
46
+ if "together" in normalized:
47
+ return (True, True)
48
+
49
+ # Handle explicit component list
50
+ enable_dashboard = "dashboard" in normalized
51
+ enable_tracker = "tracker" in normalized
52
+
53
+ # If both specified, enable both
54
+ if enable_dashboard and enable_tracker:
55
+ return (True, True)
56
+
57
+ return (enable_dashboard, enable_tracker)
58
+
59
+
60
+ def get_default_port(enable_dashboard: bool, enable_tracker: bool) -> int:
61
+ """
62
+ Get default port based on enabled components
63
+
64
+ Args:
65
+ enable_dashboard: Whether dashboard is enabled
66
+ enable_tracker: Whether tracker is enabled
67
+
68
+ Returns:
69
+ Default port number (3142 for tracker-only, 3141 otherwise)
70
+ """
71
+ if enable_tracker and not enable_dashboard:
72
+ return 3142
73
+ return 3141
74
+
75
+
76
+ def find_available_port(start_port: int = 3141, max_attempts: int = 100) -> int | None:
77
+ """
78
+ Find an available port number
79
+
80
+ Args:
81
+ start_port: Starting port number
82
+ max_attempts: Maximum number of attempts
83
+
84
+ Returns:
85
+ Available port number, None if not found
86
+ """
87
+ for port in range(start_port, start_port + max_attempts):
88
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
89
+ # If connection fails, that port is available
90
+ result = sock.connect_ex(("127.0.0.1", port))
91
+ if result != 0:
92
+ return port
93
+ return None
94
+
95
+
96
+ def run_dashboard(
97
+ host: str = "127.0.0.1",
98
+ port: int = 3141,
99
+ with_tracker: bool = False,
100
+ data_dir: str | None = None,
101
+ dev: bool = False,
102
+ project_search_mode: str = "realtime",
103
+ ) -> None:
104
+ """
105
+ Start dashboard server
106
+
107
+ Args:
108
+ host: Host name
109
+ port: Port number
110
+ with_tracker: Whether to run integrated tracker in same process
111
+ data_dir: Data directory for local data
112
+ dev: Enable development mode with auto-reload
113
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
114
+ """
115
+ # Set env vars for component mounting
116
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1"
117
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if with_tracker else "0"
118
+
119
+ if with_tracker:
120
+ os.environ["ASPARA_WITH_TRACKER"] = "1"
121
+
122
+ if dev:
123
+ os.environ["ASPARA_DEV_MODE"] = "1"
124
+
125
+ if data_dir is None:
126
+ data_dir = str(get_data_dir())
127
+
128
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
129
+
130
+ if project_search_mode:
131
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
132
+
133
+ from aspara.dashboard.router import configure_data_dir
134
+
135
+ configure_data_dir(data_dir)
136
+
137
+ print("Starting Aspara Dashboard server...")
138
+ print(f"Access http://{host}:{port} in your browser!")
139
+ print(f"Data directory: {os.path.abspath(data_dir)}")
140
+ backend = get_storage_backend() or "jsonl (default)"
141
+ print(f"Storage backend: {backend}")
142
+ if dev:
143
+ print("Development mode: auto-reload enabled")
144
+
145
+ try:
146
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
147
+ except ImportError:
148
+ print("Error: Dashboard functionality is not installed!")
149
+ print('To install: uv pip install "aspara[dashboard]"')
150
+ sys.exit(1)
151
+
152
+
153
+ def run_tui(data_dir: str | None = None) -> None:
154
+ """
155
+ Start TUI dashboard
156
+
157
+ Args:
158
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
159
+ """
160
+ if data_dir is None:
161
+ data_dir = str(get_data_dir())
162
+
163
+ print("Starting Aspara TUI...")
164
+ print(f"Data directory: {os.path.abspath(data_dir)}")
165
+
166
+ try:
167
+ from aspara.tui import run_tui as _run_tui
168
+
169
+ _run_tui(data_dir=data_dir)
170
+ except ImportError:
171
+ print("TUI functionality is not installed!")
172
+ print('To install: uv pip install "aspara[tui]"')
173
+ sys.exit(1)
174
+
175
+
176
+ def run_tracker(
177
+ host: str = "127.0.0.1",
178
+ port: int = 3142,
179
+ data_dir: str | None = None,
180
+ dev: bool = False,
181
+ storage_backend: str | None = None,
182
+ ) -> None:
183
+ """
184
+ Start tracker API server
185
+
186
+ Args:
187
+ host: Host name
188
+ port: Port number
189
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
190
+ dev: Enable development mode with auto-reload
191
+ storage_backend: Metrics storage backend (jsonl or polars)
192
+ """
193
+ # Set env vars for backward compatibility
194
+ os.environ["ASPARA_SERVE_TRACKER"] = "1"
195
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "0"
196
+
197
+ if dev:
198
+ os.environ["ASPARA_DEV_MODE"] = "1"
199
+
200
+ if storage_backend is not None:
201
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
202
+
203
+ if data_dir is None:
204
+ data_dir = str(get_data_dir())
205
+
206
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
207
+
208
+ print("Starting Aspara Tracker API server...")
209
+ print(f"Endpoint: http://{host}:{port}/tracker/api/v1")
210
+ print(f"Data directory: {os.path.abspath(data_dir)}")
211
+ backend = get_storage_backend() or "jsonl (default)"
212
+ print(f"Storage backend: {backend}")
213
+ if dev:
214
+ print("Development mode: auto-reload enabled")
215
+
216
+ try:
217
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
218
+ except ImportError:
219
+ print("Error: Tracker functionality is not installed!")
220
+ print('To install: uv pip install "aspara[tracker]"')
221
+ sys.exit(1)
222
+
223
+
224
+ def run_serve(
225
+ components: list[str],
226
+ host: str = "127.0.0.1",
227
+ port: int | None = None,
228
+ data_dir: str | None = None,
229
+ dev: bool = False,
230
+ project_search_mode: str = "realtime",
231
+ storage_backend: str | None = None,
232
+ ) -> None:
233
+ """
234
+ Start Aspara server with specified components
235
+
236
+ Args:
237
+ components: List of components to enable (dashboard, tracker, together)
238
+ host: Host name
239
+ port: Port number (auto-detected if None)
240
+ data_dir: Data directory
241
+ dev: Enable development mode with auto-reload
242
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
243
+ storage_backend: Metrics storage backend (jsonl or polars)
244
+ """
245
+ try:
246
+ enable_dashboard, enable_tracker = parse_serve_components(components)
247
+ except ValueError as e:
248
+ print(f"Error: {e}")
249
+ sys.exit(1)
250
+
251
+ # Set environment variables for component mounting
252
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1" if enable_dashboard else "0"
253
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if enable_tracker else "0"
254
+
255
+ if dev:
256
+ os.environ["ASPARA_DEV_MODE"] = "1"
257
+
258
+ if storage_backend is not None:
259
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
260
+
261
+ # Determine port
262
+ if port is None:
263
+ port = get_default_port(enable_dashboard, enable_tracker)
264
+
265
+ # Configure data directory
266
+ if data_dir is None:
267
+ data_dir = str(get_data_dir())
268
+
269
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
270
+
271
+ # Configure dashboard if enabled
272
+ if enable_dashboard:
273
+ if project_search_mode:
274
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
275
+
276
+ from aspara.dashboard.router import configure_data_dir
277
+
278
+ configure_data_dir(data_dir)
279
+
280
+ # Build component description
281
+ if enable_dashboard and enable_tracker:
282
+ component_desc = "Dashboard + Tracker"
283
+ elif enable_dashboard:
284
+ component_desc = "Dashboard"
285
+ else:
286
+ component_desc = "Tracker"
287
+
288
+ print(f"Starting Aspara {component_desc} server...")
289
+ print(f"Access http://{host}:{port} in your browser!")
290
+ print(f"Data directory: {os.path.abspath(data_dir)}")
291
+ backend = get_storage_backend() or "jsonl (default)"
292
+ print(f"Storage backend: {backend}")
293
+ if dev:
294
+ print("Development mode: auto-reload enabled")
295
+
296
+ try:
297
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
298
+ except ImportError as e:
299
+ print(f"Error: Required functionality is not installed: {e}")
300
+ sys.exit(1)
301
+
302
+
303
+ def main() -> None:
304
+ """
305
+ CLI main entry point
306
+ """
307
+ parser = argparse.ArgumentParser(description="Aspara management tool")
308
+ subparsers = parser.add_subparsers(dest="command", help="Subcommands")
309
+
310
+ dashboard_parser = subparsers.add_parser("dashboard", help="Start dashboard server")
311
+ dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
312
+ dashboard_parser.add_argument("--port", type=int, default=3141, help="Port number (default: 3141)")
313
+ dashboard_parser.add_argument("--with-tracker", action="store_true", help="Run dashboard with integrated tracker in same process")
314
+ dashboard_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
315
+ dashboard_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
316
+ dashboard_parser.add_argument(
317
+ "--project-search-mode",
318
+ choices=["realtime", "manual"],
319
+ default="realtime",
320
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
321
+ )
322
+
323
+ tracker_parser = subparsers.add_parser("tracker", help="Start tracker API server")
324
+ tracker_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
325
+ tracker_parser.add_argument("--port", type=int, default=3142, help="Port number (default: 3142)")
326
+ tracker_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
327
+ tracker_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
328
+ tracker_parser.add_argument(
329
+ "--storage-backend",
330
+ choices=["jsonl", "polars"],
331
+ default=None,
332
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
333
+ )
334
+
335
+ tui_parser = subparsers.add_parser("tui", help="Start terminal UI dashboard")
336
+ tui_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
337
+
338
+ serve_parser = subparsers.add_parser("serve", help="Start Aspara server")
339
+ serve_parser.add_argument(
340
+ "components",
341
+ nargs="*",
342
+ default=[],
343
+ help="Components to run: dashboard, tracker, together (default: dashboard only)",
344
+ )
345
+ serve_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
346
+ serve_parser.add_argument("--port", type=int, default=None, help="Port number (default: 3141 for dashboard, 3142 for tracker-only)")
347
+ serve_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
348
+ serve_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
349
+ serve_parser.add_argument(
350
+ "--project-search-mode",
351
+ choices=["realtime", "manual"],
352
+ default="realtime",
353
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
354
+ )
355
+ serve_parser.add_argument(
356
+ "--storage-backend",
357
+ choices=["jsonl", "polars"],
358
+ default=None,
359
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
360
+ )
361
+
362
+ args = parser.parse_args()
363
+
364
+ if args.command == "dashboard":
365
+ run_dashboard(
366
+ host=args.host,
367
+ port=args.port,
368
+ with_tracker=args.with_tracker,
369
+ data_dir=args.data_dir,
370
+ dev=args.dev,
371
+ project_search_mode=args.project_search_mode,
372
+ )
373
+ elif args.command == "tracker":
374
+ run_tracker(
375
+ host=args.host,
376
+ port=args.port,
377
+ data_dir=args.data_dir,
378
+ dev=args.dev,
379
+ storage_backend=args.storage_backend,
380
+ )
381
+ elif args.command == "tui":
382
+ run_tui(data_dir=args.data_dir)
383
+ elif args.command == "serve":
384
+ run_serve(
385
+ components=args.components,
386
+ host=args.host,
387
+ port=args.port,
388
+ data_dir=args.data_dir,
389
+ dev=args.dev,
390
+ project_search_mode=args.project_search_mode,
391
+ storage_backend=args.storage_backend,
392
+ )
393
+ else:
394
+ port = find_available_port(start_port=3141)
395
+ if port is None:
396
+ print("Error: No available port found!")
397
+ return
398
+
399
+ run_dashboard(port=port)
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()
src/aspara/config.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration and environment handling for Aspara."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ __all__ = [
9
+ "ResourceLimits",
10
+ "get_data_dir",
11
+ "get_resource_limits",
12
+ "get_storage_backend",
13
+ "get_project_search_mode",
14
+ "is_dev_mode",
15
+ "is_read_only",
16
+ ]
17
+
18
+
19
+ class ResourceLimits(BaseModel):
20
+ """Resource limits configuration.
21
+
22
+ Includes security-related limits (file size, JSONL lines) and
23
+ performance/resource constraints (metric names, note length, tags count).
24
+
25
+ All limits can be customized via environment variables.
26
+ Defaults are set for internal use with generous limits.
27
+ """
28
+
29
+ max_file_size: int = Field(
30
+ default=1024 * 1024 * 1024, # 1024MB (1GB)
31
+ description="Maximum file size in bytes",
32
+ )
33
+
34
+ max_jsonl_lines: int = Field(
35
+ default=1_000_000, # 1M lines
36
+ description="Maximum number of lines when reading JSONL files",
37
+ )
38
+
39
+ max_zip_size: int = Field(
40
+ default=1024 * 1024 * 1024, # 1GB
41
+ description="Maximum ZIP file size in bytes",
42
+ )
43
+
44
+ max_metric_names: int = Field(
45
+ default=100,
46
+ description="Maximum number of metric names in comma-separated list",
47
+ )
48
+
49
+ max_notes_length: int = Field(
50
+ default=10 * 1024, # 10KB
51
+ description="Maximum notes text length in characters",
52
+ )
53
+
54
+ max_tags_count: int = Field(
55
+ default=100,
56
+ description="Maximum number of tags",
57
+ )
58
+
59
+ lttb_threshold: int = Field(
60
+ default=1_000,
61
+ description="Downsample metrics using LTTB algorithm when metric series length exceeds this threshold",
62
+ )
63
+
64
+ @classmethod
65
+ def from_env(cls) -> "ResourceLimits":
66
+ """Create ResourceLimits from environment variables.
67
+
68
+ Environment variables:
69
+ - ASPARA_MAX_FILE_SIZE: Maximum file size in bytes (default: 1GB)
70
+ - ASPARA_MAX_JSONL_LINES: Maximum JSONL lines (default: 1M)
71
+ - ASPARA_MAX_ZIP_SIZE: Maximum ZIP size in bytes (default: 1GB)
72
+ - ASPARA_MAX_METRIC_NAMES: Maximum metric names (default: 100)
73
+ - ASPARA_MAX_NOTES_LENGTH: Maximum notes length (default: 10KB)
74
+ - ASPARA_MAX_TAGS_COUNT: Maximum tags count (default: 100)
75
+ - ASPARA_LTTB_THRESHOLD: Threshold for LTTB downsampling (default: 1000)
76
+ """
77
+ return cls(
78
+ max_file_size=int(os.environ.get("ASPARA_MAX_FILE_SIZE", cls.model_fields["max_file_size"].default)),
79
+ max_jsonl_lines=int(os.environ.get("ASPARA_MAX_JSONL_LINES", cls.model_fields["max_jsonl_lines"].default)),
80
+ max_zip_size=int(os.environ.get("ASPARA_MAX_ZIP_SIZE", cls.model_fields["max_zip_size"].default)),
81
+ max_metric_names=int(os.environ.get("ASPARA_MAX_METRIC_NAMES", cls.model_fields["max_metric_names"].default)),
82
+ max_notes_length=int(os.environ.get("ASPARA_MAX_NOTES_LENGTH", cls.model_fields["max_notes_length"].default)),
83
+ max_tags_count=int(os.environ.get("ASPARA_MAX_TAGS_COUNT", cls.model_fields["max_tags_count"].default)),
84
+ lttb_threshold=int(os.environ.get("ASPARA_LTTB_THRESHOLD", cls.model_fields["lttb_threshold"].default)),
85
+ )
86
+
87
+
88
+ # Global resource limits instance
89
+ _resource_limits: ResourceLimits | None = None
90
+
91
+
92
+ def get_resource_limits() -> ResourceLimits:
93
+ """Get resource limits configuration.
94
+
95
+ Returns cached instance if already initialized.
96
+ """
97
+ global _resource_limits
98
+ if _resource_limits is None:
99
+ _resource_limits = ResourceLimits.from_env()
100
+ return _resource_limits
101
+
102
+
103
+ # Forbidden system directories that cannot be used as data directories
104
+ _FORBIDDEN_PATHS = frozenset(["/", "/etc", "/sys", "/dev", "/bin", "/sbin", "/usr", "/var", "/boot", "/proc"])
105
+
106
+
107
+ def _validate_data_dir(data_path: Path) -> None:
108
+ """Validate that data directory is not a dangerous system path.
109
+
110
+ Args:
111
+ data_path: Path to validate
112
+
113
+ Raises:
114
+ ValueError: If path is a forbidden system directory
115
+ """
116
+ resolved = data_path.resolve()
117
+ resolved_str = str(resolved)
118
+
119
+ for forbidden in _FORBIDDEN_PATHS:
120
+ if resolved_str == forbidden or resolved_str.rstrip("/") == forbidden:
121
+ raise ValueError(f"ASPARA_DATA_DIR cannot be set to system directory: {forbidden}")
122
+
123
+
124
+ def get_data_dir() -> Path:
125
+ """Get the default data directory for Aspara.
126
+
127
+ Resolution priority:
128
+ 1. ASPARA_DATA_DIR environment variable (if set)
129
+ 2. XDG_DATA_HOME/aspara (if XDG_DATA_HOME is set)
130
+ 3. ~/.local/share/aspara (fallback)
131
+
132
+ Returns:
133
+ Path object pointing to the data directory.
134
+
135
+ Raises:
136
+ ValueError: If ASPARA_DATA_DIR points to a system directory
137
+
138
+ Examples:
139
+ >>> # Using ASPARA_DATA_DIR
140
+ >>> os.environ["ASPARA_DATA_DIR"] = "/custom/path"
141
+ >>> get_data_dir()
142
+ Path('/custom/path')
143
+
144
+ >>> # Using XDG_DATA_HOME
145
+ >>> os.environ["XDG_DATA_HOME"] = "/home/user/.local/share"
146
+ >>> get_data_dir()
147
+ Path('/home/user/.local/share/aspara')
148
+
149
+ >>> # Using fallback
150
+ >>> get_data_dir()
151
+ Path('/home/user/.local/share/aspara')
152
+ """
153
+ # Priority 1: ASPARA_DATA_DIR environment variable
154
+ aspara_data_dir = os.environ.get("ASPARA_DATA_DIR")
155
+ if aspara_data_dir:
156
+ data_path = Path(aspara_data_dir).expanduser().resolve()
157
+ _validate_data_dir(data_path)
158
+ return data_path
159
+
160
+ # Priority 2: XDG_DATA_HOME/aspara
161
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
162
+ if xdg_data_home:
163
+ return Path(xdg_data_home).expanduser() / "aspara"
164
+
165
+ # Priority 3: ~/.local/share/aspara (fallback)
166
+ return Path.home() / ".local" / "share" / "aspara"
167
+
168
+
169
+ def get_project_search_mode() -> str:
170
+ """Get project search mode from environment variable.
171
+
172
+ Returns:
173
+ Project search mode ("realtime" or "manual"). Defaults to "realtime".
174
+ """
175
+ mode = os.environ.get("ASPARA_PROJECT_SEARCH_MODE", "realtime")
176
+ if mode not in ("realtime", "manual"):
177
+ return "realtime"
178
+ return mode
179
+
180
+
181
+ def get_storage_backend() -> str | None:
182
+ """Get storage backend from environment variable.
183
+
184
+ Returns:
185
+ Storage backend name if ASPARA_STORAGE_BACKEND is set, None otherwise.
186
+ """
187
+ return os.environ.get("ASPARA_STORAGE_BACKEND")
188
+
189
+
190
+ def use_lttb_fast() -> bool:
191
+ """Check if fast LTTB implementation should be used.
192
+
193
+ Returns:
194
+ True if ASPARA_LTTB_FAST is set to "1", False otherwise.
195
+ """
196
+ return os.environ.get("ASPARA_LTTB_FAST") == "1"
197
+
198
+
199
+ def is_dev_mode() -> bool:
200
+ """Check if running in development mode.
201
+
202
+ Returns:
203
+ True if ASPARA_DEV_MODE is set to "1", False otherwise.
204
+ """
205
+ return os.environ.get("ASPARA_DEV_MODE") == "1"
206
+
207
+
208
+ def is_read_only() -> bool:
209
+ """Check if running in read-only mode.
210
+
211
+ Returns:
212
+ True if ASPARA_READ_ONLY is set to "1", False otherwise.
213
+ """
214
+ return os.environ.get("ASPARA_READ_ONLY") == "1"
src/aspara/dashboard/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """
2
+ Aspara metrics visualization dashboard package!
3
+ """
4
+
5
+ __version__ = "0.1.0"
src/aspara/dashboard/dependencies.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependency injection for Aspara Dashboard.
3
+
4
+ This module provides reusable dependencies for:
5
+ - Catalog instance management (ProjectCatalog, RunCatalog)
6
+ - Path parameter validation (project names, run names)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ from fastapi import Depends, HTTPException
16
+ from fastapi import Path as PathParam
17
+
18
+ from aspara.catalog import ProjectCatalog, RunCatalog
19
+ from aspara.config import get_data_dir
20
+ from aspara.utils import validators
21
+
22
+ # Mutable container for custom data directory configuration
23
+ _custom_data_dir: list[str | None] = [None]
24
+
25
+
26
+ def _get_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
27
+ """Get or create catalog instances.
28
+
29
+ Returns:
30
+ Tuple of (ProjectCatalog, RunCatalog, data_dir Path)
31
+ """
32
+ if _custom_data_dir[0] is not None:
33
+ data_dir = Path(_custom_data_dir[0])
34
+ else:
35
+ data_dir = Path(get_data_dir())
36
+ return ProjectCatalog(str(data_dir)), RunCatalog(str(data_dir)), data_dir
37
+
38
+
39
+ # Cached version for performance
40
+ @lru_cache(maxsize=1)
41
+ def _get_cached_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
42
+ """Get cached catalog instances."""
43
+ return _get_catalogs()
44
+
45
+
46
+ def get_project_catalog() -> ProjectCatalog:
47
+ """Get the ProjectCatalog singleton instance."""
48
+ return _get_cached_catalogs()[0]
49
+
50
+
51
+ def get_run_catalog() -> RunCatalog:
52
+ """Get the RunCatalog singleton instance."""
53
+ return _get_cached_catalogs()[1]
54
+
55
+
56
+ def get_data_dir_path() -> Path:
57
+ """Get the data directory path."""
58
+ return _get_cached_catalogs()[2]
59
+
60
+
61
+ def configure_data_dir(data_dir: str | None = None) -> None:
62
+ """Configure data directory and reinitialize catalogs.
63
+
64
+ This function clears the cached catalogs and reinitializes them
65
+ with the specified data directory.
66
+
67
+ Args:
68
+ data_dir: Custom data directory path. If None, uses default.
69
+ """
70
+ # Clear the cache to force reinitialization
71
+ _get_cached_catalogs.cache_clear()
72
+
73
+ # Set custom data directory
74
+ _custom_data_dir[0] = data_dir
75
+
76
+
77
+ def get_validated_project(project: Annotated[str, PathParam(description="Project name")]) -> str:
78
+ """Validate project name path parameter.
79
+
80
+ Args:
81
+ project: Project name from URL path.
82
+
83
+ Returns:
84
+ Validated project name.
85
+
86
+ Raises:
87
+ HTTPException: 400 if project name is invalid.
88
+ """
89
+ try:
90
+ validators.validate_project_name(project)
91
+ except ValueError as e:
92
+ raise HTTPException(status_code=400, detail=str(e)) from None
93
+ return project
94
+
95
+
96
+ def get_validated_run(run: Annotated[str, PathParam(description="Run name")]) -> str:
97
+ """Validate run name path parameter.
98
+
99
+ Args:
100
+ run: Run name from URL path.
101
+
102
+ Returns:
103
+ Validated run name.
104
+
105
+ Raises:
106
+ HTTPException: 400 if run name is invalid.
107
+ """
108
+ try:
109
+ validators.validate_run_name(run)
110
+ except ValueError as e:
111
+ raise HTTPException(status_code=400, detail=str(e)) from None
112
+ return run
113
+
114
+
115
+ # Type aliases for dependency injection
116
+ ValidatedProject = Annotated[str, Depends(get_validated_project)]
117
+ ValidatedRun = Annotated[str, Depends(get_validated_run)]
118
+ ProjectCatalogDep = Annotated[ProjectCatalog, Depends(get_project_catalog)]
119
+ RunCatalogDep = Annotated[RunCatalog, Depends(get_run_catalog)]
120
+ DataDirDep = Annotated[Path, Depends(get_data_dir_path)]
src/aspara/dashboard/main.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for Aspara Dashboard
3
+ """
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
+ from starlette.responses import Response
17
+
18
+ from aspara.config import is_dev_mode
19
+
20
+ from .router import router
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Global state for SSE connection management
26
+ class AppState:
27
+ """Application state for managing SSE connections during shutdown."""
28
+
29
+ def __init__(self) -> None:
30
+ self.active_sse_connections: set[asyncio.Queue] = set()
31
+ self.active_sse_tasks: set[asyncio.Task] = set()
32
+ self.shutting_down = False
33
+
34
+
35
+ app_state = AppState()
36
+
37
+
38
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
39
+ """Middleware to add security headers to all responses."""
40
+
41
+ async def dispatch(self, request: Request, call_next) -> Response:
42
+ response = await call_next(request)
43
+
44
+ # Prevent MIME type sniffing
45
+ response.headers["X-Content-Type-Options"] = "nosniff"
46
+
47
+ # Prevent clickjacking by denying framing
48
+ response.headers["X-Frame-Options"] = "DENY"
49
+
50
+ # Enable XSS filter in browsers (legacy but still useful)
51
+ response.headers["X-XSS-Protection"] = "1; mode=block"
52
+
53
+ # Content Security Policy - basic policy
54
+ # Allows self-origin scripts/styles, inline styles for chart libraries,
55
+ # and data: URIs for images (used by chart exports)
56
+ response.headers["Content-Security-Policy"] = (
57
+ "default-src 'self'; "
58
+ "script-src 'self'; "
59
+ "style-src 'self' 'unsafe-inline'; "
60
+ "img-src 'self' data:; "
61
+ "font-src 'self'; "
62
+ "connect-src 'self'; "
63
+ "frame-ancestors 'none'"
64
+ )
65
+
66
+ # Allow iframe embedding when ASPARA_ALLOW_IFRAME=1 (e.g., HF Spaces)
67
+ if os.environ.get("ASPARA_ALLOW_IFRAME") == "1":
68
+ del response.headers["X-Frame-Options"]
69
+ response.headers["Content-Security-Policy"] = (
70
+ "default-src 'self'; "
71
+ "script-src 'self'; "
72
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
73
+ "img-src 'self' data:; "
74
+ "font-src 'self' https://fonts.gstatic.com; "
75
+ "connect-src 'self'; "
76
+ "frame-ancestors https://huggingface.co https://*.hf.space"
77
+ )
78
+
79
+ return response
80
+
81
+
82
+ @asynccontextmanager
83
+ async def lifespan(app: FastAPI):
84
+ """Manage application lifecycle.
85
+
86
+ On shutdown, signal all active SSE connections to close gracefully.
87
+ In development mode, forcefully cancel SSE tasks for fast restart.
88
+ """
89
+ # Startup
90
+ yield
91
+
92
+ # Shutdown
93
+ app_state.shutting_down = True
94
+
95
+ # Signal all active SSE connections to stop
96
+ for queue in list(app_state.active_sse_connections):
97
+ # Queue might already be closed or event loop shutting down
98
+ with contextlib.suppress(RuntimeError, OSError):
99
+ await queue.put(None) # Sentinel value to signal shutdown
100
+
101
+ if is_dev_mode():
102
+ # Development mode: forcefully cancel SSE tasks for fast restart
103
+ logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks")
104
+ for task in list(app_state.active_sse_tasks):
105
+ task.cancel()
106
+
107
+ # Wait briefly for tasks to be cancelled
108
+ if app_state.active_sse_tasks:
109
+ with contextlib.suppress(asyncio.TimeoutError):
110
+ await asyncio.wait_for(
111
+ asyncio.gather(*app_state.active_sse_tasks, return_exceptions=True),
112
+ timeout=0.1,
113
+ )
114
+ logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete")
115
+ else:
116
+ # Production mode: graceful shutdown with 30 second timeout
117
+ await asyncio.sleep(0.5)
118
+
119
+
120
+ app = FastAPI(
121
+ title="Aspara Dashboard",
122
+ description="Real-time metrics visualization for machine learning experiments",
123
+ docs_url="/docs/dashboard", # /docs/dashboard としてアクセスできるようにする
124
+ redoc_url=None, # ReDocは使わない
125
+ lifespan=lifespan,
126
+ )
127
+
128
+ # Security headers middleware
129
+ app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type]
130
+
131
+ # CORS middleware - credentials disabled for security with wildcard origins
132
+ # Note: allow_credentials=True with allow_origins=["*"] is a security vulnerability
133
+ # as it allows any site to make credentialed requests to our API
134
+ app.add_middleware(
135
+ CORSMiddleware, # ty: ignore[invalid-argument-type]
136
+ allow_origins=["*"],
137
+ allow_credentials=False,
138
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
139
+ allow_headers=["Content-Type", "X-Requested-With"],
140
+ )
141
+
142
+ BASE_DIR = Path(__file__).parent
143
+ app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
144
+
145
+ app.include_router(router)
src/aspara/dashboard/models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Aspara dashboard data model definitions!
3
+ """
src/aspara/dashboard/models/metrics.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Models for metrics data.
3
+
4
+ This module defines the data models for the dashboard API.
5
+ Note: experiment concept has been removed - data structure is now project/run.
6
+ """
7
+
8
+ from datetime import datetime
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from aspara.catalog.project_catalog import ProjectInfo
13
+ from aspara.catalog.run_catalog import RunInfo
14
+
15
+ __all__ = [
16
+ "Metadata",
17
+ "MetadataUpdateRequest",
18
+ "MetricSeries",
19
+ "ProjectInfo",
20
+ "RunInfo",
21
+ ]
22
+
23
+
24
+ class Metadata(BaseModel):
25
+ """Metadata for projects and runs."""
26
+
27
+ notes: str = ""
28
+ tags: list[str] = []
29
+ created_at: datetime | None = None
30
+ updated_at: datetime | None = None
31
+
32
+
33
+ class MetadataUpdateRequest(BaseModel):
34
+ """Request model for updating metadata."""
35
+
36
+ notes: str | None = None
37
+ tags: list[str] | None = None
38
+
39
+
40
+ class MetricSeries(BaseModel):
41
+ """A single metric time series with steps, values, and timestamps.
42
+
43
+ Used in the metrics API response to represent one metric's data.
44
+ Arrays are delta-compressed where applicable.
45
+ """
46
+
47
+ steps: list[int | float]
48
+ values: list[int | float]
49
+ timestamps: list[int | float]
src/aspara/dashboard/router.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard APIRouter aggregation.
3
+
4
+ This module aggregates all route handlers from sub-modules:
5
+ - html_routes: HTML page endpoints
6
+ - api_routes: REST API endpoints
7
+ - sse_routes: Server-Sent Events streaming endpoints
8
+
9
+ Note: experiment concept has been removed - URL structure is now /projects/{project}/runs/{run}
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import APIRouter
15
+
16
+ # Re-export configure_data_dir for backwards compatibility
17
+ from .dependencies import configure_data_dir
18
+ from .routes import api_router, html_router, sse_router
19
+
20
+ router = APIRouter()
21
+ router.include_router(html_router)
22
+ router.include_router(api_router)
23
+ router.include_router(sse_router)
24
+
25
+ __all__ = ["router", "configure_data_dir"]
src/aspara/dashboard/routes/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard routes.
3
+
4
+ This package contains route handlers organized by type:
5
+ - html_routes: HTML page endpoints
6
+ - api_routes: REST API endpoints
7
+ - sse_routes: Server-Sent Events streaming endpoints
8
+ """
9
+
10
+ from .api_routes import router as api_router
11
+ from .html_routes import router as html_router
12
+ from .sse_routes import router as sse_router
13
+
14
+ __all__ = ["html_router", "api_router", "sse_router"]
src/aspara/dashboard/routes/api_routes.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ REST API routes for Aspara Dashboard.
3
+
4
+ This module handles all REST API endpoints:
5
+ - Artifacts download API
6
+ - Bulk metrics API
7
+ - Project/Run metadata APIs
8
+ - Delete APIs
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import io
15
+ import logging
16
+ import os
17
+ import zipfile
18
+ from collections import defaultdict
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+
22
+ import msgpack
23
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query
24
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
25
+
26
+ from aspara.config import get_resource_limits, is_read_only
27
+ from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
28
+ from aspara.utils import validators
29
+
30
+ from ..dependencies import (
31
+ DataDirDep,
32
+ ProjectCatalogDep,
33
+ RunCatalogDep,
34
+ ValidatedProject,
35
+ ValidatedRun,
36
+ )
37
+ from ..models.metrics import Metadata, MetadataUpdateRequest
38
+ from ..utils.compression import compress_metrics
39
+
40
+
41
+ async def verify_csrf_header(x_requested_with: str | None = Header(None, alias="X-Requested-With")) -> None:
42
+ """CSRF protection via custom header check.
43
+
44
+ Verifies that requests include the X-Requested-With header, which cannot be set
45
+ by cross-origin requests without CORS preflight. This prevents CSRF attacks.
46
+
47
+ Args:
48
+ x_requested_with: The X-Requested-With header value
49
+
50
+ Raises:
51
+ HTTPException: 403 if header is missing
52
+ """
53
+ if x_requested_with is None:
54
+ raise HTTPException(status_code=403, detail="Missing X-Requested-With header")
55
+
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ router = APIRouter()
60
+
61
+
62
+ @router.get("/api/projects/{project}/runs/{run}/artifacts/download")
63
+ async def download_artifacts_zip(
64
+ project: ValidatedProject,
65
+ run: ValidatedRun,
66
+ data_dir: DataDirDep,
67
+ ) -> StreamingResponse:
68
+ """Download all artifacts for a run as a ZIP file.
69
+
70
+ Args:
71
+ project: Project name.
72
+ run: Run name.
73
+
74
+ Returns:
75
+ StreamingResponse with ZIP file containing all artifacts.
76
+ Filename format: `{project}_{run}_artifacts_{timestamp}.zip`
77
+
78
+ Raises:
79
+ HTTPException: 400 if project/run name is invalid or total size exceeds limit,
80
+ 404 if no artifacts found.
81
+ """
82
+ # Get the artifacts directory path
83
+ artifacts_dir = data_dir / project / run / "artifacts"
84
+
85
+ # Validate path to prevent path traversal
86
+ try:
87
+ validators.validate_safe_path(artifacts_dir, data_dir)
88
+ except ValueError as e:
89
+ raise HTTPException(status_code=400, detail=f"Invalid artifacts directory path: {e}") from None
90
+
91
+ artifacts_dir_str = str(artifacts_dir)
92
+
93
+ if not os.path.exists(artifacts_dir_str):
94
+ raise HTTPException(status_code=404, detail="No artifacts found for this run")
95
+
96
+ # Single-pass: collect file info using scandir (caches stat results)
97
+ artifact_entries: list[tuple[str, str, int]] = [] # (name, path, size)
98
+ total_size = 0
99
+
100
+ with os.scandir(artifacts_dir_str) as entries:
101
+ for entry in entries:
102
+ if entry.is_file():
103
+ size = entry.stat().st_size # Uses cached stat
104
+ artifact_entries.append((entry.name, entry.path, size))
105
+ total_size += size
106
+
107
+ if not artifact_entries:
108
+ raise HTTPException(status_code=404, detail="No artifact files found")
109
+
110
+ # Check total size
111
+ limits = get_resource_limits()
112
+ if total_size > limits.max_zip_size:
113
+ raise HTTPException(
114
+ status_code=400,
115
+ detail=(f"Total artifacts size ({total_size} bytes) exceeds maximum ZIP size limit ({limits.max_zip_size} bytes)"),
116
+ )
117
+
118
+ # Create ZIP file in memory
119
+ zip_buffer = io.BytesIO()
120
+
121
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
122
+ for filename, file_path, _ in artifact_entries:
123
+ # Add file to zip with just the filename (no directory structure)
124
+ zip_file.write(file_path, filename)
125
+
126
+ zip_buffer.seek(0)
127
+
128
+ # Generate filename with timestamp
129
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
130
+ zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip"
131
+
132
+ # Return as streaming response
133
+ # Encode filename for Content-Disposition header to prevent header injection
134
+ # Use RFC 5987 encoding for non-ASCII characters
135
+ import urllib.parse
136
+
137
+ encoded_filename = urllib.parse.quote(zip_filename, safe="")
138
+ return StreamingResponse(
139
+ io.BytesIO(zip_buffer.read()),
140
+ media_type="application/zip",
141
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
142
+ )
143
+
144
+
145
+ @router.get("/api/projects/{project}/runs/metrics")
146
+ async def runs_metrics_api(
147
+ project: ValidatedProject,
148
+ run_catalog: RunCatalogDep,
149
+ runs: str,
150
+ format: str = "json",
151
+ since: int | None = Query(
152
+ default=None,
153
+ description="Filter metrics since this UNIX timestamp in milliseconds",
154
+ ),
155
+ ) -> Response:
156
+ """Get metrics for multiple runs in a single request.
157
+
158
+ Useful for comparing metrics across runs. Returns data in metric-first structure
159
+ where each metric contains data from all requested runs.
160
+
161
+ Args:
162
+ project: Project name.
163
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
164
+ format: Response format - "json" (default) or "msgpack".
165
+ since: Optional filter to only return metrics with timestamp >= since (UNIX ms).
166
+
167
+ Returns:
168
+ Response with structure: `{"project": str, "metrics": {metric: {run: {...}}}}`
169
+ - For "json" format: JSONResponse
170
+ - For "msgpack" format: Response with application/x-msgpack content type
171
+
172
+ Raises:
173
+ HTTPException: 400 if project name is invalid, format is invalid,
174
+ or too many runs specified.
175
+ """
176
+ # Validate format parameter
177
+ if format not in ("json", "msgpack"):
178
+ raise HTTPException(
179
+ status_code=400,
180
+ detail=f"Invalid format: {format}. Must be 'json' or 'msgpack'",
181
+ )
182
+
183
+ if not runs:
184
+ if format == "msgpack":
185
+ raise HTTPException(status_code=400, detail="No runs specified")
186
+ return JSONResponse(content={"error": "No runs specified"})
187
+
188
+ run_list = [r.strip() for r in runs.split(",") if r.strip()]
189
+ if not run_list:
190
+ if format == "msgpack":
191
+ raise HTTPException(status_code=400, detail="No valid runs specified")
192
+ return JSONResponse(content={"error": "No valid runs specified"})
193
+
194
+ # Validate number of runs
195
+ limits = get_resource_limits()
196
+ if len(run_list) > limits.max_metric_names: # Reuse max_metric_names limit for runs
197
+ raise HTTPException(
198
+ status_code=400,
199
+ detail=f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})",
200
+ )
201
+
202
+ # Validate each run name
203
+ for run_name in run_list:
204
+ try:
205
+ validators.validate_run_name(run_name)
206
+ except ValueError as e:
207
+ raise HTTPException(status_code=400, detail=str(e)) from None
208
+
209
+ # Convert since (UNIX ms) to datetime if provided
210
+ # Create timezone-naive datetime (matches DataFrame storage)
211
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc).replace(tzinfo=None) if since is not None else None
212
+
213
+ # Load and downsample metrics for all runs in parallel
214
+ async def load_and_downsample(
215
+ run_name: str,
216
+ ) -> tuple[str, dict[str, dict[str, list]] | None]:
217
+ """Load and downsample metrics for a single run."""
218
+ try:
219
+ df = await asyncio.to_thread(run_catalog.load_metrics, project, run_name, since_dt)
220
+ return (run_name, compress_metrics(df))
221
+ except Exception as e:
222
+ logger.warning(f"Failed to load metrics for {project}/{run_name}: {e}")
223
+ return (run_name, None)
224
+
225
+ # Execute all loads in parallel
226
+ results = await asyncio.gather(*[load_and_downsample(run_name) for run_name in run_list])
227
+
228
+ # Build metrics_by_run from results
229
+ metrics_by_run: dict[str, dict[str, dict[str, list]]] = {}
230
+ for run_name, metrics in results:
231
+ if metrics is not None:
232
+ metrics_by_run[run_name] = metrics
233
+
234
+ # Reorganize to metric-first structure using defaultdict for O(1) key insertion
235
+ metrics_data: dict[str, dict[str, dict[str, list]]] = defaultdict(dict)
236
+ for run_name, run_metrics in metrics_by_run.items():
237
+ for metric_name, metric_arrays in run_metrics.items():
238
+ metrics_data[metric_name][run_name] = metric_arrays
239
+
240
+ response_data = {"project": project, "metrics": metrics_data}
241
+
242
+ # Return response based on format
243
+ if format == "msgpack":
244
+ # Serialize to MessagePack
245
+ packed_data = msgpack.packb(response_data, use_single_float=True)
246
+ return Response(content=packed_data, media_type="application/x-msgpack")
247
+
248
+ return JSONResponse(content=response_data)
249
+
250
+
251
+ @router.get("/api/projects/{project}/metadata")
252
+ async def get_project_metadata_api(
253
+ project: ValidatedProject,
254
+ project_catalog: ProjectCatalogDep,
255
+ ) -> Metadata:
256
+ """Get project metadata.
257
+
258
+ Args:
259
+ project: Project name.
260
+
261
+ Returns:
262
+ Metadata object containing project metadata (tags, notes, etc.).
263
+
264
+ Raises:
265
+ HTTPException: 400 if project name is invalid.
266
+ """
267
+ # Use ProjectCatalog metadata API (synchronous call inside async endpoint)
268
+ metadata = project_catalog.get_metadata(project)
269
+ return Metadata.model_validate(metadata)
270
+
271
+
272
+ @router.put("/api/projects/{project}/metadata")
273
+ async def update_project_metadata_api(
274
+ project: ValidatedProject,
275
+ metadata: MetadataUpdateRequest,
276
+ project_catalog: ProjectCatalogDep,
277
+ _csrf: None = Depends(verify_csrf_header),
278
+ ) -> Metadata:
279
+ """Update project metadata.
280
+
281
+ Args:
282
+ project: Project name.
283
+ metadata: MetadataUpdateRequest containing fields to update.
284
+
285
+ Returns:
286
+ Metadata object containing the updated project metadata.
287
+
288
+ Raises:
289
+ HTTPException: 400 if project name is invalid.
290
+ """
291
+ if is_read_only():
292
+ existing = project_catalog.get_metadata(project)
293
+ return Metadata.model_validate(existing)
294
+
295
+ update_data = metadata.model_dump(exclude_none=True)
296
+
297
+ # Use ProjectCatalog metadata API
298
+ updated_metadata = project_catalog.update_metadata(project, update_data)
299
+ return Metadata.model_validate(updated_metadata)
300
+
301
+
302
+ @router.delete("/api/projects/{project}")
303
+ async def delete_project(
304
+ project: ValidatedProject,
305
+ project_catalog: ProjectCatalogDep,
306
+ _csrf: None = Depends(verify_csrf_header),
307
+ ) -> Response:
308
+ """Delete a project and all its runs.
309
+
310
+ **Warning**: This operation is irreversible.
311
+
312
+ Args:
313
+ project: Project name.
314
+
315
+ Returns:
316
+ 204 No Content on success.
317
+
318
+ Raises:
319
+ HTTPException: 400 if project name is invalid, 403 if permission denied,
320
+ 404 if project not found, 500 for unexpected errors.
321
+ """
322
+ if is_read_only():
323
+ return Response(status_code=204)
324
+
325
+ try:
326
+ project_catalog.delete(project)
327
+ logger.info(f"Deleted project: {project}")
328
+ return Response(status_code=204)
329
+ except ProjectNotFoundError as e:
330
+ raise HTTPException(status_code=404, detail=str(e)) from e
331
+ except PermissionError as e:
332
+ logger.warning(f"Permission denied deleting project {project}: {e}")
333
+ raise HTTPException(status_code=403, detail="Permission denied") from e
334
+ except Exception as e:
335
+ logger.error(f"Error deleting project {project}: {e}")
336
+ raise HTTPException(status_code=500, detail="Failed to delete project") from e
337
+
338
+
339
+ @router.get("/api/projects/{project}/runs/{run}/metadata")
340
+ async def get_run_metadata_api(
341
+ project: ValidatedProject,
342
+ run: ValidatedRun,
343
+ run_catalog: RunCatalogDep,
344
+ ) -> dict[str, Any]:
345
+ """Get run metadata.
346
+
347
+ Args:
348
+ project: Project name.
349
+ run: Run name.
350
+
351
+ Returns:
352
+ Dictionary containing run metadata (tags, notes, params, etc.).
353
+
354
+ Raises:
355
+ HTTPException: 400 if project/run name is invalid.
356
+ """
357
+ # Use RunCatalog metadata API
358
+ metadata = run_catalog.get_metadata(project, run)
359
+ return metadata
360
+
361
+
362
+ @router.put("/api/projects/{project}/runs/{run}/metadata")
363
+ async def update_run_metadata_api(
364
+ project: ValidatedProject,
365
+ run: ValidatedRun,
366
+ metadata: MetadataUpdateRequest,
367
+ run_catalog: RunCatalogDep,
368
+ _csrf: None = Depends(verify_csrf_header),
369
+ ) -> dict[str, Any]:
370
+ """Update run metadata.
371
+
372
+ Args:
373
+ project: Project name.
374
+ run: Run name.
375
+ metadata: MetadataUpdateRequest containing fields to update.
376
+
377
+ Returns:
378
+ Dictionary containing the updated run metadata.
379
+
380
+ Raises:
381
+ HTTPException: 400 if project/run name is invalid.
382
+ """
383
+ if is_read_only():
384
+ existing = run_catalog.get_metadata(project, run)
385
+ return existing
386
+
387
+ update_data = metadata.model_dump(exclude_none=True)
388
+
389
+ # Use RunCatalog metadata API
390
+ updated_metadata = run_catalog.update_metadata(project, run, update_data)
391
+ return updated_metadata
392
+
393
+
394
+ @router.delete("/api/projects/{project}/runs/{run}")
395
+ async def delete_run(
396
+ project: ValidatedProject,
397
+ run: ValidatedRun,
398
+ run_catalog: RunCatalogDep,
399
+ _csrf: None = Depends(verify_csrf_header),
400
+ ) -> Response:
401
+ """Delete a run and its artifacts.
402
+
403
+ **Warning**: This operation is irreversible.
404
+
405
+ Args:
406
+ project: Project name.
407
+ run: Run name.
408
+
409
+ Returns:
410
+ 204 No Content on success.
411
+
412
+ Raises:
413
+ HTTPException: 400 if project/run name is invalid, 403 if permission denied,
414
+ 404 if project or run not found, 500 for unexpected errors.
415
+ """
416
+ if is_read_only():
417
+ return Response(status_code=204)
418
+
419
+ try:
420
+ run_catalog.delete(project, run)
421
+ logger.info(f"Deleted run: {project}/{run}")
422
+ return Response(status_code=204)
423
+ except ProjectNotFoundError as e:
424
+ raise HTTPException(status_code=404, detail=str(e)) from e
425
+ except RunNotFoundError as e:
426
+ raise HTTPException(status_code=404, detail=str(e)) from e
427
+ except PermissionError as e:
428
+ logger.warning(f"Permission denied deleting run {project}/{run}: {e}")
429
+ raise HTTPException(status_code=403, detail="Permission denied") from e
430
+ except Exception as e:
431
+ logger.error(f"Error deleting run {project}/{run}: {e}")
432
+ raise HTTPException(status_code=500, detail="Failed to delete run") from e
src/aspara/dashboard/routes/html_routes.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HTML page routes for Aspara Dashboard.
3
+
4
+ This module handles all HTML page rendering endpoints:
5
+ - Home page (projects list)
6
+ - Project detail page
7
+ - Runs list page
8
+ - Run detail page
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from fastapi import APIRouter, HTTPException
18
+ from fastapi.responses import HTMLResponse
19
+ from starlette.requests import Request
20
+
21
+ from aspara.config import is_read_only
22
+ from aspara.exceptions import RunNotFoundError
23
+
24
+ from ..dependencies import (
25
+ ProjectCatalogDep,
26
+ RunCatalogDep,
27
+ ValidatedProject,
28
+ ValidatedRun,
29
+ )
30
+ from ..services.template_service import (
31
+ TemplateService,
32
+ create_breadcrumbs,
33
+ render_mustache_response,
34
+ )
35
+
36
+ router = APIRouter()
37
+
38
+
39
+ @router.get("/")
40
+ async def home(
41
+ request: Request,
42
+ project_catalog: ProjectCatalogDep,
43
+ ) -> HTMLResponse:
44
+ """Render the projects list page."""
45
+ projects = project_catalog.get_projects()
46
+
47
+ # Format projects for template, including metadata tags
48
+ formatted_projects = []
49
+ for project in projects:
50
+ metadata = project_catalog.get_metadata(project.name)
51
+ tags = metadata.get("tags") or []
52
+ formatted_projects.append(TemplateService.format_project_for_template(project, tags))
53
+
54
+ from aspara.config import get_project_search_mode
55
+
56
+ project_search_mode = get_project_search_mode()
57
+
58
+ context = {
59
+ "page_title": "Aspara",
60
+ "breadcrumbs": create_breadcrumbs([{"label": "Home", "is_home": True}]),
61
+ "projects": formatted_projects,
62
+ "has_projects": len(formatted_projects) > 0,
63
+ "project_search_mode": project_search_mode,
64
+ "read_only": is_read_only(),
65
+ }
66
+
67
+ html = render_mustache_response("projects_list", context)
68
+ return HTMLResponse(content=html)
69
+
70
+
71
+ @router.get("/projects/{project}")
72
+ async def project_detail(
73
+ request: Request,
74
+ project: ValidatedProject,
75
+ project_catalog: ProjectCatalogDep,
76
+ run_catalog: RunCatalogDep,
77
+ ) -> HTMLResponse:
78
+ """Project detail page - shows metrics charts."""
79
+ # Check if project exists
80
+ if not project_catalog.exists(project):
81
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
82
+
83
+ runs = run_catalog.get_runs(project)
84
+
85
+ # Format runs for template (excluding corrupted runs)
86
+ formatted_runs = []
87
+ for run in runs:
88
+ formatted = TemplateService.format_run_for_project_detail(run)
89
+ if formatted is not None:
90
+ formatted_runs.append(formatted)
91
+
92
+ # Find the most recent last_update from all runs
93
+ project_last_update = None
94
+ if runs:
95
+ last_updates = [r.last_update for r in runs if r.last_update is not None]
96
+ if last_updates:
97
+ project_last_update = max(last_updates)
98
+
99
+ context = {
100
+ "page_title": f"{project} - Metrics",
101
+ "breadcrumbs": create_breadcrumbs([
102
+ {"label": "Home", "url": "/", "is_home": True},
103
+ {"label": project},
104
+ ]),
105
+ "project": project,
106
+ "runs": formatted_runs,
107
+ "has_runs": len(formatted_runs) > 0,
108
+ "run_count": len(formatted_runs),
109
+ "formatted_project_last_update": (project_last_update.strftime("%b %d, %Y at %I:%M %p") if project_last_update else "N/A"),
110
+ "read_only": is_read_only(),
111
+ }
112
+
113
+ html = render_mustache_response("project_detail", context)
114
+ return HTMLResponse(content=html)
115
+
116
+
117
+ @router.get("/projects/{project}/runs")
118
+ async def list_project_runs(
119
+ request: Request,
120
+ project: ValidatedProject,
121
+ project_catalog: ProjectCatalogDep,
122
+ run_catalog: RunCatalogDep,
123
+ ) -> HTMLResponse:
124
+ """List runs in a project."""
125
+ # Check if project exists
126
+ if not project_catalog.exists(project):
127
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
128
+
129
+ runs = run_catalog.get_runs(project)
130
+
131
+ # Format runs for template
132
+ formatted_runs = [TemplateService.format_run_for_list(run) for run in runs]
133
+
134
+ context = {
135
+ "page_title": f"{project} - Runs",
136
+ "breadcrumbs": create_breadcrumbs([
137
+ {"label": "Home", "url": "/", "is_home": True},
138
+ {"label": project, "url": f"/projects/{project}"},
139
+ {"label": "Runs"},
140
+ ]),
141
+ "project": project,
142
+ "runs": formatted_runs,
143
+ "has_runs": len(formatted_runs) > 0,
144
+ "read_only": is_read_only(),
145
+ }
146
+
147
+ html = render_mustache_response("runs_list", context)
148
+ return HTMLResponse(content=html)
149
+
150
+
151
+ @router.get("/projects/{project}/runs/{run}")
152
+ async def get_run(
153
+ request: Request,
154
+ project: ValidatedProject,
155
+ run: ValidatedRun,
156
+ project_catalog: ProjectCatalogDep,
157
+ run_catalog: RunCatalogDep,
158
+ ) -> HTMLResponse:
159
+ """Get run details including parameters and metrics."""
160
+ # Check if project exists
161
+ if not project_catalog.exists(project):
162
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
163
+
164
+ # Get Run information and check if it's corrupted
165
+ try:
166
+ current_run = run_catalog.get(project, run)
167
+ except RunNotFoundError as e:
168
+ raise HTTPException(status_code=404, detail=f"Run '{run}' not found in project '{project}'") from e
169
+
170
+ is_corrupted = current_run.is_corrupted
171
+ error_message = current_run.error_message
172
+ run_tags = current_run.tags
173
+
174
+ # Load metrics, artifacts, and metadata in parallel
175
+ df_metrics, artifacts, metadata = await asyncio.gather(
176
+ asyncio.to_thread(run_catalog.load_metrics, project, run),
177
+ run_catalog.get_artifacts_async(project, run),
178
+ run_catalog.get_run_config_async(project, run),
179
+ )
180
+
181
+ # Extract params from metadata
182
+ params: dict[str, Any] = {}
183
+ params.update(metadata.get("params", {}))
184
+ params.update(metadata.get("config", {}))
185
+
186
+ # Format data for template
187
+ formatted_params = [{"key": k, "value": v} for k, v in params.items()]
188
+
189
+ # Get latest metrics for scalar display from wide-format DataFrame
190
+ latest_metrics: dict[str, Any] = {}
191
+ if len(df_metrics) > 0:
192
+ # Get last row (latest metrics)
193
+ last_row = df_metrics.tail(1).to_dicts()[0]
194
+ # Extract metric columns (those starting with underscore)
195
+ for col, value in last_row.items():
196
+ if col.startswith("_") and value is not None:
197
+ # Remove underscore prefix
198
+ metric_name = col[1:]
199
+ latest_metrics[metric_name] = value
200
+
201
+ formatted_latest_metrics = [{"key": k, "value": f"{v:.4f}" if isinstance(v, int | float) else str(v)} for k, v in latest_metrics.items()]
202
+
203
+ # Get run start time from DataFrame
204
+ start_time = None
205
+ if len(df_metrics) > 0 and "timestamp" in df_metrics.columns:
206
+ start_time = df_metrics.select("timestamp").to_series().min()
207
+
208
+ context = {
209
+ "page_title": f"{run} - Details",
210
+ "breadcrumbs": create_breadcrumbs([
211
+ {"label": "Home", "url": "/", "is_home": True},
212
+ {"label": project, "url": f"/projects/{project}"},
213
+ {"label": "Runs", "url": f"/projects/{project}/runs"},
214
+ {"label": run},
215
+ ]),
216
+ "project": project,
217
+ "run_name": run,
218
+ "params": formatted_params,
219
+ "has_params": len(formatted_params) > 0,
220
+ "latest_metrics": formatted_latest_metrics,
221
+ "has_latest_metrics": len(formatted_latest_metrics) > 0,
222
+ "formatted_start_time": (start_time.strftime("%B %d, %Y at %I:%M %p") if isinstance(start_time, datetime) else "N/A"),
223
+ "duration": "N/A", # We don't have duration data in current format
224
+ "has_tags": len(run_tags) > 0,
225
+ "tags": run_tags,
226
+ "artifacts": [TemplateService.format_artifact_for_template(artifact) for artifact in artifacts],
227
+ "has_artifacts": len(artifacts) > 0,
228
+ "is_corrupted": is_corrupted,
229
+ "error_message": error_message,
230
+ "read_only": is_read_only(),
231
+ }
232
+
233
+ html = render_mustache_response("run_detail", context)
234
+ return HTMLResponse(content=html)
src/aspara/dashboard/routes/sse_routes.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Server-Sent Events (SSE) routes for Aspara Dashboard.
3
+
4
+ This module handles real-time streaming endpoints:
5
+ - Multiple runs metrics streaming
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections.abc import Coroutine
13
+ from contextlib import suppress
14
+ from datetime import datetime, timezone
15
+ from typing import Any, cast
16
+
17
+ from fastapi import APIRouter, Query
18
+ from sse_starlette.sse import EventSourceResponse
19
+
20
+ from aspara.config import get_resource_limits, is_dev_mode
21
+ from aspara.models import MetricRecord, StatusRecord
22
+ from aspara.utils import validators
23
+
24
+ from ..dependencies import RunCatalogDep, ValidatedProject
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ @router.get("/api/projects/{project}/runs/stream")
32
+ async def stream_multiple_runs(
33
+ project: ValidatedProject,
34
+ run_catalog: RunCatalogDep,
35
+ runs: str,
36
+ since: int = Query(
37
+ ...,
38
+ description="Filter metrics since this UNIX timestamp in milliseconds",
39
+ ),
40
+ ) -> EventSourceResponse:
41
+ """Stream metrics for multiple runs using Server-Sent Events (SSE).
42
+
43
+ Args:
44
+ project: Project name.
45
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
46
+ since: Filter to only stream metrics with timestamp >= since (required, UNIX ms).
47
+
48
+ Returns:
49
+ EventSourceResponse streaming metric and status events from all specified runs.
50
+ Event types:
51
+ - `metric`: `{"event": "metric", "data": <MetricRecord JSON>}`
52
+ - `status`: `{"event": "status", "data": <StatusRecord JSON>}`
53
+
54
+ Raises:
55
+ HTTPException: 400 if project/run name is invalid, 422 if since is missing.
56
+ """
57
+ logger.info(f"[SSE ENDPOINT] Called with project={project}, runs={runs}")
58
+
59
+ from ..main import app_state
60
+
61
+ if not runs:
62
+
63
+ async def no_runs_error_generator():
64
+ yield {"event": "error", "data": "No runs specified"}
65
+
66
+ return EventSourceResponse(no_runs_error_generator())
67
+
68
+ # Parse and validate run names
69
+ run_list = [r.strip() for r in runs.split(",") if r.strip()]
70
+
71
+ if not run_list:
72
+
73
+ async def no_valid_runs_error_generator():
74
+ yield {"event": "error", "data": "No valid runs specified"}
75
+
76
+ return EventSourceResponse(no_valid_runs_error_generator())
77
+
78
+ # Validate run count limit
79
+ limits = get_resource_limits()
80
+ if len(run_list) > limits.max_metric_names:
81
+ too_many_runs_msg = f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})"
82
+
83
+ async def too_many_runs_error_generator():
84
+ yield {"event": "error", "data": too_many_runs_msg}
85
+
86
+ return EventSourceResponse(too_many_runs_error_generator())
87
+
88
+ # Validate each run name
89
+ for run_name in run_list:
90
+ try:
91
+ validators.validate_run_name(run_name)
92
+ except ValueError:
93
+ invalid_run_msg = f"Invalid run name: {run_name}"
94
+
95
+ async def invalid_run_error_generator(msg: str = invalid_run_msg):
96
+ yield {"event": "error", "data": msg}
97
+
98
+ return EventSourceResponse(invalid_run_error_generator())
99
+
100
+ # Convert UNIX ms to datetime
101
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc)
102
+
103
+ async def event_generator():
104
+ logger.info(f"[SSE] event_generator started for project={project}, runs={run_list}")
105
+
106
+ # Register current task for dev mode forced cancellation
107
+ current_task = asyncio.current_task()
108
+ if current_task is not None:
109
+ app_state.active_sse_tasks.add(current_task)
110
+
111
+ # Create shutdown queue for this connection
112
+ shutdown_queue: asyncio.Queue[None] = asyncio.Queue()
113
+ app_state.active_sse_connections.add(shutdown_queue)
114
+
115
+ # Use new subscribe() method with singleton watcher
116
+ targets = {project: run_list}
117
+ metrics_iterator = run_catalog.subscribe(targets, since=since_dt).__aiter__()
118
+ logger.info("[SSE] Created metrics_iterator using subscribe()")
119
+
120
+ # In dev mode, use shorter timeout for faster shutdown detection
121
+ dev_mode = is_dev_mode()
122
+ wait_timeout = 1.0 if dev_mode else None
123
+
124
+ # Track pending metric task to avoid re-creating it after timeout
125
+ # IMPORTANT: Cancelling a task that's awaiting inside an async generator
126
+ # will close the generator. We must NOT cancel metric_task on timeout.
127
+ pending_metric_task: asyncio.Task[MetricRecord | StatusRecord] | None = None
128
+
129
+ try:
130
+ while True:
131
+ # Check shutdown flag in dev mode
132
+ if dev_mode and app_state.shutting_down:
133
+ logger.info("[SSE] Dev mode: shutdown flag detected")
134
+ # Cancel pending metric_task before exiting
135
+ if pending_metric_task is not None:
136
+ pending_metric_task.cancel()
137
+ with suppress(asyncio.CancelledError):
138
+ await pending_metric_task
139
+ break
140
+
141
+ # Create metric_task only if we don't have a pending one
142
+ if pending_metric_task is None:
143
+ metric_coro = cast(
144
+ "Coroutine[Any, Any, MetricRecord | StatusRecord]",
145
+ metrics_iterator.__anext__(),
146
+ )
147
+ pending_metric_task = asyncio.create_task(metric_coro, name="metric_task")
148
+
149
+ # Always create a new shutdown_task
150
+ shutdown_coro = cast("Coroutine[Any, Any, Any]", shutdown_queue.get())
151
+ shutdown_task = asyncio.create_task(shutdown_coro, name="shutdown_task")
152
+
153
+ try:
154
+ done, pending = await asyncio.wait(
155
+ [pending_metric_task, shutdown_task],
156
+ return_when=asyncio.FIRST_COMPLETED,
157
+ timeout=wait_timeout,
158
+ )
159
+ except asyncio.CancelledError:
160
+ # Cancelled by lifespan handler in dev mode
161
+ logger.info("[SSE] Task cancelled (dev mode shutdown)")
162
+ pending_metric_task.cancel()
163
+ shutdown_task.cancel()
164
+ with suppress(asyncio.CancelledError):
165
+ await pending_metric_task
166
+ with suppress(asyncio.CancelledError):
167
+ await shutdown_task
168
+ raise
169
+
170
+ # Handle timeout (dev mode only)
171
+ if not done:
172
+ # Timeout occurred - only cancel shutdown_task, NOT metric_task
173
+ # Cancelling metric_task would close the async generator!
174
+ shutdown_task.cancel()
175
+ with suppress(asyncio.CancelledError):
176
+ await shutdown_task
177
+ # pending_metric_task is kept and will be reused in next iteration
178
+ continue
179
+
180
+ logger.debug(f"[SSE] asyncio.wait returned: done={[t.get_name() for t in done]}, pending={[t.get_name() for t in pending]}")
181
+
182
+ # Cancel pending tasks (but NOT metric_task if it's pending)
183
+ if shutdown_task in pending:
184
+ shutdown_task.cancel()
185
+ with suppress(asyncio.CancelledError):
186
+ await shutdown_task
187
+
188
+ # Check which task completed
189
+ if pending_metric_task in done:
190
+ # Reset so we create a new task in next iteration
191
+ completed_task = pending_metric_task
192
+ pending_metric_task = None
193
+ try:
194
+ record = completed_task.result()
195
+ if isinstance(record, MetricRecord):
196
+ logger.debug(f"[SSE] Sending metric to client: run={record.run}, step={record.step}")
197
+ yield {"event": "metric", "data": record.model_dump_json()}
198
+ elif isinstance(record, StatusRecord):
199
+ logger.info(f"[SSE] Sending status update to client: run={record.run}, status={record.status}")
200
+ yield {"event": "status", "data": record.model_dump_json()}
201
+ except StopAsyncIteration:
202
+ logger.info("[SSE] No more records (StopAsyncIteration)")
203
+ break
204
+ elif shutdown_task in done:
205
+ logger.info("[SSE] Shutdown requested")
206
+ # Cancel metric_task since we're shutting down
207
+ if pending_metric_task is not None:
208
+ pending_metric_task.cancel()
209
+ with suppress(asyncio.CancelledError):
210
+ await pending_metric_task
211
+ break
212
+
213
+ except asyncio.CancelledError:
214
+ logger.info("[SSE] Generator cancelled")
215
+ raise
216
+ except Exception as e:
217
+ logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True)
218
+ yield {"event": "error", "data": str(e)}
219
+ finally:
220
+ # Clean up: remove this connection from active set
221
+ logger.info("[SSE] event_generator finished, cleaning up")
222
+ app_state.active_sse_connections.discard(shutdown_queue)
223
+ if current_task is not None:
224
+ app_state.active_sse_tasks.discard(current_task)
225
+ # Cancel pending metric task if still running
226
+ if pending_metric_task is not None and not pending_metric_task.done():
227
+ pending_metric_task.cancel()
228
+ with suppress(asyncio.CancelledError):
229
+ await pending_metric_task
230
+ # Close the async generator to trigger watcher unsubscribe
231
+ try:
232
+ await asyncio.wait_for(metrics_iterator.aclose(), timeout=1.0)
233
+ except asyncio.TimeoutError:
234
+ logger.warning("[SSE] Timeout closing metrics_iterator")
235
+ except Exception as e:
236
+ logger.warning(f"[SSE] Error closing metrics_iterator: {e}")
237
+
238
+ logger.info(f"[SSE ENDPOINT] Returning EventSourceResponse for runs={run_list}")
239
+ return EventSourceResponse(event_generator())
src/aspara/dashboard/services/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard services.
3
+
4
+ This package contains business logic services for the dashboard.
5
+ """
6
+
7
+ from .template_service import TemplateService, create_breadcrumbs, render_mustache_response
8
+
9
+ __all__ = ["TemplateService", "create_breadcrumbs", "render_mustache_response"]
src/aspara/dashboard/services/template_service.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template rendering service for Aspara Dashboard.
3
+
4
+ Provides Mustache template rendering and context formatting utilities.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pystache
14
+
15
+ from aspara.catalog import ProjectInfo, RunInfo
16
+
17
+ BASE_DIR = Path(__file__).parent.parent
18
+ _mustache_renderer = pystache.Renderer(search_dirs=[str(BASE_DIR / "templates")])
19
+
20
+
21
+ def create_breadcrumbs(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
22
+ """Create standardized breadcrumbs with consistent formatting.
23
+
24
+ Args:
25
+ items: List of breadcrumb items with 'label' and optional 'url' keys.
26
+ First item is assumed to be Home.
27
+
28
+ Returns:
29
+ List of breadcrumb items with consistent is_not_first flags.
30
+ """
31
+ result = []
32
+
33
+ for i, item in enumerate(items):
34
+ crumb = item.copy()
35
+ crumb["is_not_first"] = i != 0
36
+
37
+ # Add home icon to first item if not already specified
38
+ if i == 0 and "is_home" not in crumb:
39
+ crumb["is_home"] = True
40
+
41
+ result.append(crumb)
42
+
43
+ return result
44
+
45
+
46
+ def render_mustache_response(template_name: str, context: dict[str, Any]) -> str:
47
+ """Render mustache template with context.
48
+
49
+ Args:
50
+ template_name: Name of the template file (without extension).
51
+ context: Template context dictionary.
52
+
53
+ Returns:
54
+ Rendered HTML string.
55
+ """
56
+ # Add common context variables
57
+ context.update({
58
+ "current_year": datetime.now().year,
59
+ "page_title": context.get("page_title", "Aspara"),
60
+ })
61
+
62
+ # Render content template
63
+ content = _mustache_renderer.render_name(template_name, context)
64
+
65
+ # Render layout with content
66
+ layout_context = context.copy()
67
+ layout_context["content"] = content
68
+
69
+ return _mustache_renderer.render_name("layout", layout_context)
70
+
71
+
72
+ class TemplateService:
73
+ """Service for template rendering and data formatting.
74
+
75
+ This class provides methods for formatting data objects for template rendering.
76
+ """
77
+
78
+ @staticmethod
79
+ def format_project_for_template(project: ProjectInfo, tags: list[str] | None = None) -> dict[str, Any]:
80
+ """Format a ProjectInfo for template rendering.
81
+
82
+ Args:
83
+ project: ProjectInfo object.
84
+ tags: Optional list of tags from metadata.
85
+
86
+ Returns:
87
+ Dictionary suitable for template rendering.
88
+ """
89
+ return {
90
+ "name": project.name,
91
+ "run_count": project.run_count or 0,
92
+ "last_update": int(project.last_update.timestamp() * 1000) if project.last_update else 0,
93
+ "formatted_last_update": (project.last_update.strftime("%B %d, %Y at %I:%M %p") if project.last_update else "N/A"),
94
+ "tags": tags or [],
95
+ }
96
+
97
+ @staticmethod
98
+ def format_run_for_list(run: RunInfo) -> dict[str, Any]:
99
+ """Format a RunInfo for runs list template.
100
+
101
+ Args:
102
+ run: RunInfo object.
103
+
104
+ Returns:
105
+ Dictionary suitable for runs list template rendering.
106
+ """
107
+ return {
108
+ "name": run.name,
109
+ "param_count": run.param_count or 0,
110
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
111
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
112
+ "is_corrupted": run.is_corrupted,
113
+ "error_message": run.error_message,
114
+ "tags": run.tags,
115
+ "has_tags": len(run.tags) > 0,
116
+ "is_finished": run.is_finished,
117
+ "is_wip": run.status.value == "wip",
118
+ "status": run.status.value,
119
+ }
120
+
121
+ @staticmethod
122
+ def format_run_for_project_detail(run: RunInfo) -> dict[str, Any] | None:
123
+ """Format a RunInfo for project detail template (excludes corrupted runs).
124
+
125
+ Args:
126
+ run: RunInfo object.
127
+
128
+ Returns:
129
+ Dictionary suitable for project detail template rendering,
130
+ or None if the run is corrupted.
131
+ """
132
+ if run.is_corrupted:
133
+ return None
134
+
135
+ return {
136
+ "name": run.name,
137
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
138
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
139
+ "is_finished": run.is_finished,
140
+ "is_wip": run.status.value == "wip",
141
+ "status": run.status.value,
142
+ }
143
+
144
+ @staticmethod
145
+ def format_artifact_for_template(artifact: dict[str, Any]) -> dict[str, Any]:
146
+ """Format an artifact for template rendering with category flags.
147
+
148
+ Args:
149
+ artifact: Artifact dictionary.
150
+
151
+ Returns:
152
+ Dictionary with category boolean flags added.
153
+ """
154
+ category = artifact.get("category")
155
+ return {
156
+ **artifact,
157
+ "is_code": category == "code",
158
+ "is_config": category == "config",
159
+ "is_model": category == "model",
160
+ "is_data": category == "data",
161
+ "is_other": category == "other" or category is None,
162
+ }
src/aspara/dashboard/static/css/input.css ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @source "../../templates/**/*.mustache";
3
+
4
+ @theme {
5
+ --color-action: #2C2520;
6
+ --color-action-hover: #1a1512;
7
+ --color-action-disabled: #d4cfc9;
8
+
9
+ --color-secondary: #8B7F75;
10
+ --color-secondary-hover: #6B5F55;
11
+
12
+ --color-accent: #CC785C;
13
+ --color-accent-hover: #B5654A;
14
+ --color-accent-light: #E8A892;
15
+
16
+ --color-base-bg: #F5F3F0;
17
+ --color-base-border: #E6E3E0;
18
+ --color-base-surface: #FDFCFB;
19
+
20
+ --color-text-primary: #2C2520;
21
+ --color-text-secondary: #6B5F55;
22
+ --color-text-muted: #9B8F85;
23
+
24
+ --color-status-error: #C84C3C;
25
+ --color-status-success: #5A8B6F;
26
+ --color-status-warning: #D4864E;
27
+
28
+ --font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
29
+ --font-family-mono: "JetBrains Mono", Consolas, Monaco, monospace;
30
+
31
+ --radius-button: 0.5rem;
32
+ }
33
+
34
+ /* Custom styles beyond Tailwind */
35
+ .plot-container {
36
+ height: 24rem;
37
+ background: #FDFCFB;
38
+ padding: 1rem;
39
+ border-radius: 0;
40
+ border: 1px solid #E6E3E0;
41
+ box-shadow: none;
42
+ }
43
+
44
+ .sidebar {
45
+ width: 16rem;
46
+ background: #FDFCFB;
47
+ border: 1px solid #E6E3E0;
48
+ box-shadow: none;
49
+ }
50
+
51
+ /* Sidebar animation - optimized for responsiveness */
52
+ #runs-sidebar {
53
+ transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
54
+ }
55
+
56
+ /* Accessibility: respect reduced motion preference */
57
+ @media (prefers-reduced-motion: reduce) {
58
+ #runs-sidebar {
59
+ transition: none;
60
+ }
61
+ }
62
+
63
+ .content-area {
64
+ padding: 2rem;
65
+ flex: 1;
66
+ }
67
+
68
+ /* === @jcubic/tagger Aspara Theme Override === */
69
+
70
+ /* Container - border and background only */
71
+ .tagger {
72
+ border: 1px solid var(--color-base-border);
73
+ border-radius: 0.375rem;
74
+ background: var(--color-base-surface);
75
+ }
76
+
77
+ /* Tags - color and border-radius only, keep original padding/display */
78
+ .tagger > ul > li:not(.tagger-new) > :first-child {
79
+ background: var(--color-base-bg);
80
+ border: 1px solid var(--color-base-border);
81
+ border-radius: 9999px;
82
+ /* padding is kept as original 4px 4px 4px 8px - do not override */
83
+ }
84
+
85
+ /* Tag text color */
86
+ .tagger > ul > li:not(.tagger-new) span.label {
87
+ color: var(--color-text-muted);
88
+ }
89
+
90
+ /* Close button */
91
+ .tagger li a.close {
92
+ color: var(--color-text-muted);
93
+ }
94
+ .tagger li a.close:hover {
95
+ color: var(--color-text-primary);
96
+ }
97
+
98
+ /* Input field */
99
+ .tagger .tagger-new input {
100
+ font-size: 0.75rem;
101
+ color: var(--color-text-primary);
102
+ }
103
+ .tagger .tagger-new input::placeholder {
104
+ color: var(--color-text-muted);
105
+ }
106
+
107
+ /* === Status Icon Styles (based on data-status attribute) === */
108
+
109
+ [data-status="wip"] {
110
+ @apply animate-pulse text-status-warning;
111
+ }
112
+
113
+ [data-status="completed"] {
114
+ @apply text-status-success;
115
+ }
116
+
117
+ [data-status="failed"] {
118
+ @apply text-status-error;
119
+ }
120
+
121
+ [data-status="maybe_failed"] {
122
+ @apply text-status-warning;
123
+ }
124
+
125
+ /* === Note Editor Cursor Styles === */
126
+
127
+ /* Placeholder (Add note...) cursor */
128
+ .note-content .text-text-muted.italic {
129
+ cursor: pointer;
130
+ }
131
+
132
+ /* Edit link cursor */
133
+ .note-edit-btn {
134
+ cursor: pointer;
135
+ }
136
+
137
+ /* === Dialog Styles === */
138
+
139
+ dialog.delete-dialog {
140
+ position: fixed;
141
+ top: 50%;
142
+ left: 50%;
143
+ transform: translate(-50%, -50%);
144
+ margin: 0;
145
+ border: 1px solid var(--color-base-border);
146
+ border-radius: 0.5rem;
147
+ background: var(--color-base-surface);
148
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
149
+ max-width: 28rem;
150
+ width: calc(100% - 2rem);
151
+ }
152
+
153
+ dialog.delete-dialog::backdrop {
154
+ background: rgb(0 0 0 / 0.5);
155
+ }
156
+
157
+ /* === Card Interactive Styles === */
158
+
159
+ /* Common card styles */
160
+ .card-interactive {
161
+ @apply transition-colors duration-150 outline-none;
162
+ @apply hover:border-accent;
163
+ @apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2;
164
+ }
165
+
166
+ /* Reduced motion support */
167
+ @media (prefers-reduced-motion: reduce) {
168
+ .card-interactive {
169
+ @apply transition-none;
170
+ }
171
+ }
src/aspara/dashboard/static/css/tagger.css ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**@license
2
+ * _____
3
+ * |_ _|___ ___ ___ ___ ___
4
+ * | | | .'| . | . | -_| _|
5
+ * |_| |__,|_ |_ |___|_|
6
+ * |___|___| version 0.6.2
7
+ *
8
+ * Tagger - Zero dependency, Vanilla JavaScript Tag Editor
9
+ *
10
+ * Copyright (c) 2018-2024 Jakub T. Jankiewicz <https://jcubic.pl/me>
11
+ * Released under the MIT license
12
+ */
13
+ /* Border/background defined in input.css */
14
+ .tagger input[type="hidden"] {
15
+ /* fix for bootstrap */
16
+ display: none;
17
+ }
18
+ .tagger > ul {
19
+ display: flex;
20
+ width: 100%;
21
+ align-items: center;
22
+ padding: 4px 5px 0;
23
+ justify-content: space-between;
24
+ box-sizing: border-box;
25
+ height: auto;
26
+ flex: 0 0 auto;
27
+ overflow-y: auto;
28
+ margin: 0;
29
+ list-style: none;
30
+ }
31
+ .tagger > ul > li {
32
+ padding-bottom: 0.4rem;
33
+ margin: 0.4rem 5px 4px;
34
+ }
35
+ .tagger > ul > li:not(.tagger-new) a,
36
+ .tagger > ul > li:not(.tagger-new) a:visited {
37
+ text-decoration: none;
38
+ /* color defined in input.css */
39
+ }
40
+ .tagger > ul > li:not(.tagger-new) > :first-child {
41
+ padding: 4px 4px 4px 8px;
42
+ /* background, border, border-radius defined in input.css */
43
+ }
44
+ .tagger > ul > li:not(.tagger-new) > span,
45
+ .tagger > ul > li:not(.tagger-new) > a > span {
46
+ white-space: nowrap;
47
+ }
48
+ .tagger li a.close {
49
+ padding: 4px;
50
+ margin-left: 4px;
51
+ /* for bootstrap */
52
+ float: none;
53
+ filter: alpha(opacity=100);
54
+ opacity: 1;
55
+ font-size: 16px;
56
+ line-height: 16px;
57
+ }
58
+ .tagger li a.close:hover {
59
+ /* color defined in input.css */
60
+ }
61
+ .tagger .tagger-new input {
62
+ border: none;
63
+ outline: none;
64
+ box-shadow: none;
65
+ width: 100%;
66
+ padding-left: 0;
67
+ box-sizing: border-box;
68
+ background: transparent;
69
+ }
70
+ .tagger .tagger-new {
71
+ flex-grow: 1;
72
+ position: relative;
73
+ min-width: 40px;
74
+ width: 1px;
75
+ }
76
+ .tagger.wrap > ul {
77
+ flex-wrap: wrap;
78
+ justify-content: start;
79
+ }
src/aspara/dashboard/static/favicon.ico ADDED
src/aspara/dashboard/static/images/aspara-icon.png ADDED
src/aspara/dashboard/static/js/api/delete-api.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Delete API utility functions
3
+ * Pure API calls without UI logic
4
+ */
5
+
6
+ /**
7
+ * Delete a project via API
8
+ * @param {string} projectName - The project name to delete
9
+ * @returns {Promise<object>} - Response data
10
+ * @throws {Error} - API error
11
+ */
12
+ export async function deleteProjectApi(projectName) {
13
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}`, {
14
+ method: 'DELETE',
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ 'X-Requested-With': 'XMLHttpRequest',
18
+ },
19
+ });
20
+
21
+ if (!response.ok) {
22
+ const errorData = await response.json();
23
+ throw new Error(errorData.detail || 'Unknown error');
24
+ }
25
+
26
+ // Handle 204 No Content responses
27
+ if (response.status === 204) {
28
+ return { message: 'Project deleted successfully' };
29
+ }
30
+
31
+ return response.json();
32
+ }
33
+
34
+ /**
35
+ * Delete a run via API
36
+ * @param {string} projectName - The project name
37
+ * @param {string} runName - The run name to delete
38
+ * @returns {Promise<object>} - Response data
39
+ * @throws {Error} - API error
40
+ */
41
+ export async function deleteRunApi(projectName, runName) {
42
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/runs/${encodeURIComponent(runName)}`, {
43
+ method: 'DELETE',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'X-Requested-With': 'XMLHttpRequest',
47
+ },
48
+ });
49
+
50
+ if (!response.ok) {
51
+ const errorData = await response.json();
52
+ throw new Error(errorData.detail || 'Unknown error');
53
+ }
54
+
55
+ // Handle 204 No Content responses
56
+ if (response.status === 204) {
57
+ return { message: 'Run deleted successfully' };
58
+ }
59
+
60
+ return response.json();
61
+ }
src/aspara/dashboard/static/js/chart.js ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Canvas-based chart component for metrics visualization.
3
+ * Supports multiple series, zoom, hover tooltips, and data export.
4
+ */
5
+ import { ChartColorPalette } from './chart/color-palette.js';
6
+ import { ChartControls } from './chart/controls.js';
7
+ import { ChartExport } from './chart/export.js';
8
+ import { ChartInteraction } from './chart/interaction.js';
9
+ import { ChartRenderer } from './chart/renderer.js';
10
+
11
+ export class Chart {
12
+ // Chart layout constants
13
+ static MARGIN = 60;
14
+ static CANVAS_SCALE_FACTOR = 1.5; // Reduced from 2.5 for better performance
15
+ static SIZE_UPDATE_RETRY_DELAY_MS = 100;
16
+ static FULLSCREEN_UPDATE_DELAY_MS = 100;
17
+ static MIN_DRAG_DISTANCE = 10;
18
+
19
+ // Grid constants
20
+ static X_GRID_COUNT = 10;
21
+ static Y_GRID_COUNT = 8;
22
+ static Y_PADDING_RATIO = 0.1;
23
+
24
+ // Style constants
25
+ static LINE_WIDTH = 1.5; // Normal view
26
+ static LINE_WIDTH_FULLSCREEN = 2.5; // Fullscreen view
27
+ static GRID_LINE_WIDTH = 0.5;
28
+ static LEGEND_ITEM_SPACING = 16;
29
+ static LEGEND_LINE_LENGTH = 16;
30
+ static LEGEND_TEXT_OFFSET = 4;
31
+ static LEGEND_Y_OFFSET = 30;
32
+
33
+ // Animation constants
34
+ static ANIMATION_PULSE_DURATION_MS = 1000;
35
+
36
+ constructor(containerId, options = {}) {
37
+ this.container = document.querySelector(containerId);
38
+ if (!this.container) {
39
+ throw new Error(`Container ${containerId} not found`);
40
+ }
41
+
42
+ this.data = null;
43
+ this.width = 0;
44
+ this.height = 0;
45
+ this.onZoomChange = options.onZoomChange || null;
46
+
47
+ // Color palette for managing series styles
48
+ this.colorPalette = new ChartColorPalette();
49
+
50
+ // Initialize modules
51
+ this.renderer = new ChartRenderer(this);
52
+ this.chartExport = new ChartExport(this);
53
+ this.interaction = new ChartInteraction(this, this.renderer);
54
+ this.controls = new ChartControls(this, this.chartExport);
55
+
56
+ this.hoverPoint = null;
57
+
58
+ this.zoomState = {
59
+ active: false,
60
+ startX: null,
61
+ startY: null,
62
+ currentX: null,
63
+ currentY: null,
64
+ };
65
+ this.zoom = { x: null, y: null };
66
+
67
+ // Fullscreen event handler (stored for cleanup)
68
+ this.fullscreenChangeHandler = null;
69
+
70
+ // Data range cache for performance optimization
71
+ this._cachedDataRanges = null;
72
+ this._lastDataRef = null;
73
+
74
+ this.init();
75
+ }
76
+
77
+ init() {
78
+ this.container.innerHTML = '';
79
+
80
+ this.canvas = document.createElement('canvas');
81
+ this.canvas.style.border = '1px solid #e5e7eb';
82
+ this.canvas.style.display = 'block';
83
+ this.canvas.style.maxWidth = '100%';
84
+
85
+ this.container.appendChild(this.canvas);
86
+ this.ctx = this.canvas.getContext('2d');
87
+
88
+ this.ctx.imageSmoothingEnabled = true;
89
+ this.ctx.imageSmoothingQuality = 'high';
90
+
91
+ // For throttling draw calls
92
+ this.pendingDraw = false;
93
+
94
+ this.updateSize();
95
+ this.interaction.setupEventListeners();
96
+ this.setupFullscreenListener();
97
+ this.controls.create();
98
+ }
99
+
100
+ updateSize() {
101
+ // Use clientWidth/clientHeight to get size excluding border
102
+ const rect = this.container.getBoundingClientRect?.();
103
+ const width = this.container.clientWidth || rect?.width || 0;
104
+ const height = this.container.clientHeight || rect?.height || 0;
105
+
106
+ // Retry later if container is not yet visible
107
+ if (width === 0 || height === 0) {
108
+ // Avoid infinite retry loops - only retry if we haven't set a size yet
109
+ if (this.width === 0 && this.height === 0) {
110
+ setTimeout(() => this.updateSize(), Chart.SIZE_UPDATE_RETRY_DELAY_MS);
111
+ }
112
+ return;
113
+ }
114
+
115
+ this.width = width;
116
+ this.height = height;
117
+
118
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
119
+
120
+ const dpr = window.devicePixelRatio || 1;
121
+ const totalScale = dpr * Chart.CANVAS_SCALE_FACTOR;
122
+
123
+ // Set internal canvas resolution (high-DPI)
124
+ this.canvas.width = this.width * totalScale;
125
+ this.canvas.height = this.height * totalScale;
126
+
127
+ // Set CSS display size to exact pixel values (matching internal aspect ratio)
128
+ this.canvas.style.width = `${this.width}px`;
129
+ this.canvas.style.height = `${this.height}px`;
130
+ this.canvas.style.display = 'block';
131
+
132
+ this.ctx.scale(totalScale, totalScale);
133
+
134
+ // Redraw if data is already set
135
+ if (this.data) {
136
+ this.draw();
137
+ }
138
+ }
139
+
140
+ setData(data) {
141
+ this.data = data;
142
+ // Invalidate data range cache when data changes
143
+ this._cachedDataRanges = null;
144
+ this._lastDataRef = null;
145
+ if (data?.series) {
146
+ this.colorPalette.ensureRunStyles(data.series.map((s) => s.name));
147
+ }
148
+ this.draw();
149
+ }
150
+
151
+ /**
152
+ * Get cached data ranges, recalculating only when data changes.
153
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
154
+ */
155
+ _getDataRanges() {
156
+ // Check if data reference changed
157
+ if (this.data?.series !== this._lastDataRef) {
158
+ this._lastDataRef = this.data?.series;
159
+ this._cachedDataRanges = this._calculateDataRanges();
160
+ }
161
+ return this._cachedDataRanges;
162
+ }
163
+
164
+ /**
165
+ * Calculate data ranges from all series.
166
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
167
+ */
168
+ _calculateDataRanges() {
169
+ if (!this.data?.series?.length) return null;
170
+
171
+ let xMin = Number.POSITIVE_INFINITY;
172
+ let xMax = Number.NEGATIVE_INFINITY;
173
+ let yMin = Number.POSITIVE_INFINITY;
174
+ let yMax = Number.NEGATIVE_INFINITY;
175
+
176
+ for (const series of this.data.series) {
177
+ if (!series.data?.steps?.length) continue;
178
+ const { steps, values } = series.data;
179
+
180
+ // steps are sorted, so O(1) for min/max
181
+ xMin = Math.min(xMin, steps[0]);
182
+ xMax = Math.max(xMax, steps[steps.length - 1]);
183
+
184
+ // values min/max
185
+ for (let i = 0; i < values.length; i++) {
186
+ if (values[i] < yMin) yMin = values[i];
187
+ if (values[i] > yMax) yMax = values[i];
188
+ }
189
+ }
190
+
191
+ if (xMin === Number.POSITIVE_INFINITY) return null;
192
+
193
+ return { xMin, xMax, yMin, yMax };
194
+ }
195
+
196
+ draw() {
197
+ // Skip drawing if canvas size is not yet initialized
198
+ if (this.width === 0 || this.height === 0) {
199
+ return;
200
+ }
201
+
202
+ this.ctx.fillStyle = 'white';
203
+ this.ctx.fillRect(0, 0, this.width, this.height);
204
+
205
+ if (!this.data) {
206
+ console.warn('Chart.draw(): No data set');
207
+ return;
208
+ }
209
+
210
+ if (!this.data.series || !Array.isArray(this.data.series)) {
211
+ console.error('Chart.draw(): Invalid data format - series must be an array');
212
+ return;
213
+ }
214
+
215
+ if (this.data.series.length === 0) {
216
+ console.warn('Chart.draw(): Empty series array');
217
+ return;
218
+ }
219
+
220
+ const margin = Chart.MARGIN;
221
+ const plotWidth = this.width - margin * 2;
222
+ const plotHeight = this.height - margin * 2;
223
+
224
+ // Use cached data ranges for performance
225
+ const ranges = this._getDataRanges();
226
+ if (!ranges) {
227
+ console.warn('Chart.draw(): No valid data points in series');
228
+ return;
229
+ }
230
+
231
+ let { xMin, xMax, yMin, yMax } = ranges;
232
+
233
+ if (this.zoom.x) {
234
+ xMin = this.zoom.x.min;
235
+ xMax = this.zoom.x.max;
236
+ }
237
+ if (this.zoom.y) {
238
+ yMin = this.zoom.y.min;
239
+ yMax = this.zoom.y.max;
240
+ }
241
+
242
+ const yRange = yMax - yMin;
243
+ const yMinPadded = yMin - yRange * Chart.Y_PADDING_RATIO;
244
+ const yMaxPadded = yMax + yRange * Chart.Y_PADDING_RATIO;
245
+
246
+ this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
247
+ this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
248
+
249
+ // Clip to plot area
250
+ this.ctx.save();
251
+ this.ctx.beginPath();
252
+ this.ctx.rect(margin, margin, plotWidth, plotHeight);
253
+ this.ctx.clip();
254
+
255
+ for (const series of this.data.series) {
256
+ if (!series.data?.steps?.length) continue;
257
+ const { steps, values } = series.data;
258
+
259
+ const style = this.colorPalette.getRunStyle(series.name);
260
+
261
+ this.ctx.strokeStyle = style.borderColor;
262
+ this.ctx.lineWidth = this.getLineWidth();
263
+ this.ctx.lineCap = 'round';
264
+ this.ctx.lineJoin = 'round';
265
+
266
+ // Apply border dash pattern
267
+ if (style.borderDash && style.borderDash.length > 0) {
268
+ this.ctx.setLineDash(style.borderDash);
269
+ } else {
270
+ this.ctx.setLineDash([]);
271
+ }
272
+
273
+ this.ctx.beginPath();
274
+
275
+ for (let i = 0; i < steps.length; i++) {
276
+ const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth;
277
+ const y = margin + plotHeight - ((values[i] - yMinPadded) / (yMaxPadded - yMinPadded)) * plotHeight;
278
+
279
+ if (i === 0) {
280
+ this.ctx.moveTo(x, y);
281
+ } else {
282
+ this.ctx.lineTo(x, y);
283
+ }
284
+ }
285
+
286
+ this.ctx.stroke();
287
+ this.ctx.setLineDash([]); // Reset dash pattern
288
+ }
289
+
290
+ this.ctx.restore();
291
+ this.renderer.drawLegend();
292
+ this.interaction.drawHoverEffects();
293
+ this.interaction.drawZoomSelection();
294
+ }
295
+
296
+ getLineWidth() {
297
+ if (document.fullscreenElement === this.container) {
298
+ return Chart.LINE_WIDTH_FULLSCREEN;
299
+ }
300
+ return Chart.LINE_WIDTH;
301
+ }
302
+
303
+ getRunStyle(seriesName) {
304
+ return this.colorPalette.getRunStyle(seriesName);
305
+ }
306
+
307
+ setupFullscreenListener() {
308
+ // Store handler for cleanup
309
+ this.fullscreenChangeHandler = () => {
310
+ setTimeout(() => {
311
+ this.updateSize();
312
+ }, Chart.FULLSCREEN_UPDATE_DELAY_MS);
313
+ };
314
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
315
+ }
316
+
317
+ resetZoom() {
318
+ this.zoom.x = null;
319
+ this.zoom.y = null;
320
+ this.draw();
321
+ }
322
+
323
+ setExternalZoom(zoomState) {
324
+ if (zoomState?.x) {
325
+ this.zoom.x = { ...zoomState.x };
326
+ this.draw();
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Add a new data point to an existing series (SoA format)
332
+ * @param {string} runName - Name of the run
333
+ * @param {number} step - Step number
334
+ * @param {number} value - Metric value
335
+ */
336
+ addDataPoint(runName, step, value) {
337
+ console.log(`[Chart] addDataPoint called: run=${runName}, step=${step}, value=${value}`);
338
+
339
+ if (!this.data || !this.data.series) {
340
+ console.warn('[Chart] No data or series available');
341
+ return;
342
+ }
343
+
344
+ // Find the series for this run
345
+ let series = this.data.series.find((s) => s.name === runName);
346
+
347
+ if (!series) {
348
+ // Create new series if it doesn't exist (SoA format)
349
+ series = {
350
+ name: runName,
351
+ data: { steps: [], values: [] },
352
+ };
353
+ this.data.series.push(series);
354
+ }
355
+
356
+ const { steps, values } = series.data;
357
+
358
+ // Binary search to find insertion position
359
+ let left = 0;
360
+ let right = steps.length;
361
+ while (left < right) {
362
+ const mid = (left + right) >> 1;
363
+ if (steps[mid] < step) {
364
+ left = mid + 1;
365
+ } else if (steps[mid] > step) {
366
+ right = mid;
367
+ } else {
368
+ // Exact match - update existing value
369
+ values[mid] = value;
370
+ this.scheduleDraw();
371
+ return;
372
+ }
373
+ }
374
+
375
+ // Insert at the found position (usually at the end, so O(1) in practice)
376
+ steps.splice(left, 0, step);
377
+ values.splice(left, 0, value);
378
+
379
+ // Invalidate data range cache when data changes
380
+ this._cachedDataRanges = null;
381
+
382
+ // Schedule redraw using requestAnimationFrame to throttle updates
383
+ this.scheduleDraw();
384
+ }
385
+
386
+ /**
387
+ * Schedule a draw operation using requestAnimationFrame
388
+ * This prevents excessive redraws when multiple data points arrive rapidly
389
+ */
390
+ scheduleDraw() {
391
+ console.log('[Chart] scheduleDraw called, pendingDraw:', this.pendingDraw);
392
+
393
+ if (this.pendingDraw) {
394
+ return; // Draw already scheduled
395
+ }
396
+
397
+ this.pendingDraw = true;
398
+ requestAnimationFrame(() => {
399
+ console.log('[Chart] requestAnimationFrame callback executing');
400
+ this.pendingDraw = false;
401
+ this.draw();
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Clean up event listeners and resources.
407
+ */
408
+ destroy() {
409
+ if (this.fullscreenChangeHandler) {
410
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
411
+ this.fullscreenChangeHandler = null;
412
+ }
413
+ if (this.interaction) {
414
+ this.interaction.removeEventListeners();
415
+ }
416
+ if (this.controls) {
417
+ this.controls.destroy();
418
+ }
419
+ }
420
+ }
src/aspara/dashboard/static/js/chart/color-palette.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChartColorPalette - Color management for chart series
3
+ * Handles color generation, style assignment, and run-to-style mapping
4
+ */
5
+ export class ChartColorPalette {
6
+ constructor() {
7
+ // Modern 16-color base palette with well-distributed hues for easy differentiation
8
+ // Colors are arranged by hue (0-360°) with ~22.5° spacing for maximum visual distinction
9
+ // Red-family colors and dark blues have varied saturation for better distinction
10
+ this.baseColors = [
11
+ '#FF3B47', // red (0°) - high saturation
12
+ '#F77F00', // orange (30°)
13
+ '#FCBF49', // yellow (45°)
14
+ '#06D6A0', // mint/turquoise (165°)
15
+ '#118AB2', // blue (195°)
16
+ '#69808b', // dark blue (200°) - higher saturation, more vivid
17
+ '#4361EE', // bright blue (225°)
18
+ '#7209B7', // purple (270°)
19
+ '#E85D9A', // magenta (330°) - medium saturation, lighter
20
+ '#B8252D', // crimson (355°) - lower saturation, darker
21
+ '#F4A261', // peach (35°)
22
+ '#2A9D8F', // teal (170°)
23
+ '#408828', // dark teal (190°) - lower saturation, more muted
24
+ '#3A86FF', // sky blue (215°)
25
+ '#8338EC', // violet (265°)
26
+ '#FF1F7D', // hot pink (340°) - very high saturation
27
+ ];
28
+
29
+ // Border dash patterns for additional differentiation
30
+ this.borderDashPatterns = [
31
+ [], // solid
32
+ [6, 4], // dashed
33
+ [2, 3], // dotted
34
+ [10, 3, 2, 3], // dash-dot
35
+ ];
36
+
37
+ // Registry to maintain stable run->style mapping
38
+ this.runStyleRegistry = new Map();
39
+ this.nextStyleIndex = 0;
40
+ }
41
+
42
+ /**
43
+ * Convert hex color to RGB
44
+ * @param {string} hex - Hex color string
45
+ * @returns {Object|null} RGB object or null if invalid
46
+ */
47
+ hexToRgb(hex) {
48
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
49
+ return result
50
+ ? {
51
+ r: Number.parseInt(result[1], 16),
52
+ g: Number.parseInt(result[2], 16),
53
+ b: Number.parseInt(result[3], 16),
54
+ }
55
+ : null;
56
+ }
57
+
58
+ /**
59
+ * Convert RGB to HSL
60
+ * @param {number} r - Red (0-255)
61
+ * @param {number} g - Green (0-255)
62
+ * @param {number} b - Blue (0-255)
63
+ * @returns {Object} HSL object
64
+ */
65
+ rgbToHsl(r, g, b) {
66
+ const rNorm = r / 255;
67
+ const gNorm = g / 255;
68
+ const bNorm = b / 255;
69
+
70
+ const max = Math.max(rNorm, gNorm, bNorm);
71
+ const min = Math.min(rNorm, gNorm, bNorm);
72
+ let h;
73
+ let s;
74
+ const l = (max + min) / 2;
75
+
76
+ if (max === min) {
77
+ h = 0;
78
+ s = 0;
79
+ } else {
80
+ const d = max - min;
81
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
82
+
83
+ switch (max) {
84
+ case rNorm:
85
+ h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
86
+ break;
87
+ case gNorm:
88
+ h = ((bNorm - rNorm) / d + 2) / 6;
89
+ break;
90
+ case bNorm:
91
+ h = ((rNorm - gNorm) / d + 4) / 6;
92
+ break;
93
+ }
94
+ }
95
+
96
+ return { h: h * 360, s: s * 100, l: l * 100 };
97
+ }
98
+
99
+ /**
100
+ * Apply variant transformation to HSL color
101
+ * @param {Object} hsl - HSL color object
102
+ * @param {number} variantIndex - Variant index (0-2)
103
+ * @returns {Object} Modified HSL object
104
+ */
105
+ applyVariant(hsl, variantIndex) {
106
+ const variants = [
107
+ { sDelta: 0, lDelta: 0 }, // normal
108
+ { sDelta: -15, lDelta: -6 }, // muted
109
+ { sDelta: 8, lDelta: 6 }, // bright
110
+ ];
111
+
112
+ const variant = variants[variantIndex];
113
+ let s = hsl.s + variant.sDelta;
114
+ let l = hsl.l + variant.lDelta;
115
+
116
+ // Clamp to safe ranges
117
+ s = Math.max(35, Math.min(95, s));
118
+ l = Math.max(30, Math.min(70, l));
119
+
120
+ return { h: hsl.h, s, l };
121
+ }
122
+
123
+ /**
124
+ * Convert HSL to CSS string
125
+ * @param {Object} hsl - HSL color object
126
+ * @returns {string} CSS HSL string
127
+ */
128
+ hslToString(hsl) {
129
+ return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`;
130
+ }
131
+
132
+ /**
133
+ * Generate style for a given style index
134
+ * @param {number} styleIndex - Style index
135
+ * @returns {Object} Style object with borderColor, backgroundColor, borderDash
136
+ */
137
+ generateStyle(styleIndex) {
138
+ const M = this.baseColors.length; // 16
139
+ const V = 3; // variants
140
+ const D = this.borderDashPatterns.length; // 4
141
+
142
+ const baseIndex = styleIndex % M;
143
+ const variantIndex = Math.floor(styleIndex / M) % V;
144
+ const dashIndex = Math.floor(styleIndex / (M * V)) % D;
145
+
146
+ // Get base color and convert to HSL
147
+ const hex = this.baseColors[baseIndex];
148
+ const rgb = this.hexToRgb(hex);
149
+ const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b);
150
+
151
+ // Apply variant
152
+ const variantHsl = this.applyVariant(hsl, variantIndex);
153
+ const borderColor = this.hslToString(variantHsl);
154
+
155
+ // Get border dash pattern
156
+ const borderDash = this.borderDashPatterns[dashIndex];
157
+
158
+ return {
159
+ borderColor,
160
+ backgroundColor: borderColor,
161
+ borderDash,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Ensure all runs have stable styles assigned
167
+ * @param {Array<string>} runIds - Array of run IDs
168
+ */
169
+ ensureRunStyles(runIds) {
170
+ // Sort run IDs for stable ordering
171
+ const sortedRunIds = [...new Set(runIds)].sort();
172
+
173
+ for (const runId of sortedRunIds) {
174
+ if (!this.runStyleRegistry.has(runId)) {
175
+ const style = this.generateStyle(this.nextStyleIndex);
176
+ this.runStyleRegistry.set(runId, style);
177
+ this.nextStyleIndex++;
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get style for a specific run
184
+ * @param {string} runId - Run ID
185
+ * @returns {Object} Style object
186
+ */
187
+ getRunStyle(runId) {
188
+ return this.runStyleRegistry.get(runId) || this.generateStyle(0);
189
+ }
190
+
191
+ /**
192
+ * Reset the style registry
193
+ */
194
+ reset() {
195
+ this.runStyleRegistry.clear();
196
+ this.nextStyleIndex = 0;
197
+ }
198
+ }
src/aspara/dashboard/static/js/chart/controls.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_RESET_ZOOM } from '../html-utils.js';
2
+
3
+ export class ChartControls {
4
+ constructor(chart, chartExport) {
5
+ this.chart = chart;
6
+ this.chartExport = chartExport;
7
+ this.buttonContainer = null;
8
+ this.resetButton = null;
9
+ this.fullSizeButton = null;
10
+ this.downloadButton = null;
11
+ this.downloadMenu = null;
12
+
13
+ // Document click handler (stored for cleanup)
14
+ this.documentClickHandler = null;
15
+ }
16
+
17
+ create() {
18
+ this.chart.container.style.position = 'relative';
19
+
20
+ this.buttonContainer = document.createElement('div');
21
+ this.buttonContainer.style.cssText = `
22
+ position: absolute;
23
+ top: 10px;
24
+ right: 10px;
25
+ display: flex;
26
+ gap: 8px;
27
+ z-index: 10;
28
+ `;
29
+
30
+ this.createResetButton();
31
+ this.createFullSizeButton();
32
+ this.createDownloadButton();
33
+
34
+ this.buttonContainer.appendChild(this.resetButton);
35
+ this.buttonContainer.appendChild(this.fullSizeButton);
36
+ this.buttonContainer.appendChild(this.downloadButton);
37
+ this.chart.container.appendChild(this.buttonContainer);
38
+ }
39
+
40
+ createResetButton() {
41
+ this.resetButton = document.createElement('button');
42
+ this.resetButton.innerHTML = ICON_RESET_ZOOM;
43
+ this.resetButton.title = 'Reset zoom';
44
+ this.resetButton.style.cssText = `
45
+ width: 32px;
46
+ height: 32px;
47
+ border: 1px solid #ddd;
48
+ background: white;
49
+ cursor: pointer;
50
+ border-radius: 6px;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ color: #555;
55
+ `;
56
+
57
+ this.attachButtonHover(this.resetButton);
58
+ this.resetButton.addEventListener('click', () => this.chart.resetZoom());
59
+ }
60
+
61
+ createFullSizeButton() {
62
+ this.fullSizeButton = document.createElement('button');
63
+ this.fullSizeButton.innerHTML = ICON_FULLSCREEN;
64
+ this.fullSizeButton.title = 'Fit to full size';
65
+ this.fullSizeButton.style.cssText = `
66
+ width: 32px;
67
+ height: 32px;
68
+ border: 1px solid #ddd;
69
+ background: white;
70
+ cursor: pointer;
71
+ border-radius: 6px;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ color: #555;
76
+ `;
77
+
78
+ this.attachButtonHover(this.fullSizeButton);
79
+ this.fullSizeButton.addEventListener('click', () => this.fitToFullSize());
80
+ }
81
+
82
+ createDownloadButton() {
83
+ this.downloadButton = document.createElement('button');
84
+ this.downloadButton.innerHTML = ICON_DOWNLOAD;
85
+ this.downloadButton.title = 'Download data';
86
+ this.downloadButton.style.cssText = `
87
+ width: 32px;
88
+ height: 32px;
89
+ border: 1px solid #ddd;
90
+ background: white;
91
+ cursor: pointer;
92
+ border-radius: 6px;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ color: #555;
97
+ position: relative;
98
+ `;
99
+
100
+ this.downloadMenu = document.createElement('div');
101
+ this.downloadMenu.style.cssText = `
102
+ position: absolute;
103
+ top: 100%;
104
+ right: 0;
105
+ background: white;
106
+ border: 1px solid #ddd;
107
+ border-radius: 6px;
108
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
109
+ display: none;
110
+ flex-direction: column;
111
+ width: 120px;
112
+ z-index: 20;
113
+ `;
114
+
115
+ const downloadOptions = [
116
+ { format: 'CSV', label: 'CSV format' },
117
+ { format: 'SVG', label: 'SVG image' },
118
+ { format: 'PNG', label: 'PNG image' },
119
+ ];
120
+
121
+ for (const option of downloadOptions) {
122
+ const menuItem = document.createElement('button');
123
+ menuItem.textContent = option.label;
124
+ menuItem.style.cssText = `
125
+ padding: 8px 12px;
126
+ text-align: left;
127
+ background: none;
128
+ border: none;
129
+ cursor: pointer;
130
+ font-size: 13px;
131
+ color: #333;
132
+ `;
133
+ menuItem.addEventListener('mouseenter', () => {
134
+ menuItem.style.background = '#f5f5f5';
135
+ });
136
+ menuItem.addEventListener('mouseleave', () => {
137
+ menuItem.style.background = 'none';
138
+ });
139
+ menuItem.addEventListener('click', (e) => {
140
+ e.stopPropagation();
141
+ this.chartExport.downloadData(option.format);
142
+ this.toggleDownloadMenu(false);
143
+ });
144
+ this.downloadMenu.appendChild(menuItem);
145
+ }
146
+
147
+ this.downloadButton.appendChild(this.downloadMenu);
148
+
149
+ this.attachButtonHover(this.downloadButton);
150
+ this.downloadButton.addEventListener('click', () => this.toggleDownloadMenu());
151
+
152
+ // Store handler for cleanup
153
+ this.documentClickHandler = (e) => {
154
+ if (!this.downloadButton.contains(e.target)) {
155
+ this.toggleDownloadMenu(false);
156
+ }
157
+ };
158
+ document.addEventListener('click', this.documentClickHandler);
159
+ }
160
+
161
+ fitToFullSize() {
162
+ if (!document.fullscreenElement) {
163
+ if (this.chart.container.requestFullscreen) {
164
+ this.chart.container.requestFullscreen();
165
+ } else if (this.chart.container.webkitRequestFullscreen) {
166
+ this.chart.container.webkitRequestFullscreen();
167
+ } else if (this.chart.container.mozRequestFullScreen) {
168
+ this.chart.container.mozRequestFullScreen();
169
+ } else if (this.chart.container.msRequestFullscreen) {
170
+ this.chart.container.msRequestFullscreen();
171
+ }
172
+ } else {
173
+ if (document.exitFullscreen) {
174
+ document.exitFullscreen();
175
+ } else if (document.webkitExitFullscreen) {
176
+ document.webkitExitFullscreen();
177
+ } else if (document.mozCancelFullScreen) {
178
+ document.mozCancelFullScreen();
179
+ } else if (document.msExitFullscreen) {
180
+ document.msExitFullscreen();
181
+ }
182
+ }
183
+ }
184
+
185
+ toggleDownloadMenu(forceState) {
186
+ const isVisible = this.downloadMenu.style.display === 'flex';
187
+ const newState = forceState !== undefined ? forceState : !isVisible;
188
+
189
+ this.downloadMenu.style.display = newState ? 'flex' : 'none';
190
+ }
191
+
192
+ /**
193
+ * Attach hover effect to a button element
194
+ * @param {HTMLButtonElement} button - Button element to attach hover effect
195
+ */
196
+ attachButtonHover(button) {
197
+ button.addEventListener('mouseenter', () => {
198
+ button.style.background = '#f5f5f5';
199
+ button.style.borderColor = '#bbb';
200
+ });
201
+ button.addEventListener('mouseleave', () => {
202
+ button.style.background = 'white';
203
+ button.style.borderColor = '#ddd';
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Clean up event listeners and remove DOM elements.
209
+ */
210
+ destroy() {
211
+ if (this.documentClickHandler) {
212
+ document.removeEventListener('click', this.documentClickHandler);
213
+ this.documentClickHandler = null;
214
+ }
215
+ if (this.buttonContainer?.parentNode) {
216
+ this.buttonContainer.parentNode.removeChild(this.buttonContainer);
217
+ }
218
+ this.buttonContainer = null;
219
+ this.resetButton = null;
220
+ this.fullSizeButton = null;
221
+ this.downloadButton = null;
222
+ this.downloadMenu = null;
223
+ }
224
+ }
src/aspara/dashboard/static/js/chart/export-utils.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure utility functions for chart export
3
+ * These functions have no side effects and are easy to test
4
+ */
5
+
6
+ /**
7
+ * Generate CSV content from series data (SoA format)
8
+ * @param {Array} series - Array of series objects with name and data in SoA format
9
+ * @returns {string} CSV formatted string
10
+ */
11
+ export function generateCSVContent(series) {
12
+ const lines = ['series,step,value'];
13
+
14
+ for (const s of series) {
15
+ if (!s.data?.steps?.length) continue;
16
+ const { steps, values } = s.data;
17
+
18
+ const seriesName = s.name.replace(/"/g, '""');
19
+
20
+ for (let i = 0; i < steps.length; i++) {
21
+ lines.push(`"${seriesName}",${steps[i]},${values[i]}`);
22
+ }
23
+ }
24
+
25
+ return `${lines.join('\n')}\n`;
26
+ }
27
+
28
+ /**
29
+ * Sanitize a string for use as a filename
30
+ * @param {string} name - Original name
31
+ * @returns {string} Sanitized filename
32
+ */
33
+ export function sanitizeFileName(name) {
34
+ return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
35
+ }
36
+
37
+ /**
38
+ * Get export filename from chart data
39
+ * @param {Object} data - Chart data object with optional title and series
40
+ * @returns {string} Filename without extension
41
+ */
42
+ export function getExportFileName(data) {
43
+ if (data.title) {
44
+ return sanitizeFileName(data.title);
45
+ }
46
+ if (data.series && data.series.length === 1) {
47
+ return sanitizeFileName(data.series[0].name);
48
+ }
49
+ return 'chart';
50
+ }
51
+
52
+ /**
53
+ * Calculate dimensions for zoomed/unzoomed export
54
+ * @param {Object} chart - Chart object with zoom, width, height, and MARGIN
55
+ * @returns {Object} Dimensions info including useZoomedArea, margin, plotWidth, plotHeight
56
+ */
57
+ export function calculateExportDimensions(chart) {
58
+ const useZoomedArea = chart.zoom.x !== null || chart.zoom.y !== null;
59
+ const margin = chart.constructor.MARGIN;
60
+ const plotWidth = chart.width - margin * 2;
61
+ const plotHeight = chart.height - margin * 2;
62
+
63
+ return { useZoomedArea, margin, plotWidth, plotHeight };
64
+ }
65
+
66
+ /**
67
+ * Build filename with optional zoom suffix
68
+ * @param {string} baseName - Base filename
69
+ * @param {boolean} isZoomed - Whether to add zoomed suffix
70
+ * @returns {string} Final filename
71
+ */
72
+ export function buildExportFileName(baseName, isZoomed) {
73
+ return isZoomed ? `${baseName}_zoomed` : baseName;
74
+ }
src/aspara/dashboard/static/js/chart/export.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { buildExportFileName, calculateExportDimensions, generateCSVContent, getExportFileName } from './export-utils.js';
2
+
3
+ export class ChartExport {
4
+ constructor(chart) {
5
+ this.chart = chart;
6
+ }
7
+
8
+ downloadData(format) {
9
+ if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) {
10
+ return;
11
+ }
12
+
13
+ switch (format) {
14
+ case 'CSV':
15
+ this.downloadCSV();
16
+ break;
17
+ case 'SVG':
18
+ this.downloadSVG();
19
+ break;
20
+ case 'PNG':
21
+ this.downloadPNG();
22
+ break;
23
+ }
24
+ }
25
+
26
+ downloadCSV() {
27
+ const csvContent = generateCSVContent(this.chart.data.series);
28
+
29
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
30
+ const url = URL.createObjectURL(blob);
31
+ const link = document.createElement('a');
32
+
33
+ const fileName = getExportFileName(this.chart.data);
34
+
35
+ link.setAttribute('href', url);
36
+ link.setAttribute('download', `${fileName}.csv`);
37
+ link.style.display = 'none';
38
+
39
+ document.body.appendChild(link);
40
+ link.click();
41
+ document.body.removeChild(link);
42
+ URL.revokeObjectURL(url);
43
+ }
44
+
45
+ downloadSVG() {
46
+ const svgNamespace = 'http://www.w3.org/2000/svg';
47
+ const svg = document.createElementNS(svgNamespace, 'svg');
48
+
49
+ const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
50
+
51
+ if (useZoomedArea) {
52
+ svg.setAttribute('width', plotWidth);
53
+ svg.setAttribute('height', plotHeight);
54
+ svg.setAttribute('viewBox', `0 0 ${plotWidth} ${plotHeight}`);
55
+
56
+ const background = document.createElementNS(svgNamespace, 'rect');
57
+ background.setAttribute('width', plotWidth);
58
+ background.setAttribute('height', plotHeight);
59
+ background.setAttribute('fill', 'white');
60
+ svg.appendChild(background);
61
+
62
+ const tempCanvas = document.createElement('canvas');
63
+ tempCanvas.width = plotWidth;
64
+ tempCanvas.height = plotHeight;
65
+ const tempCtx = tempCanvas.getContext('2d');
66
+
67
+ tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
68
+
69
+ const canvasImage = document.createElementNS(svgNamespace, 'image');
70
+ canvasImage.setAttribute('width', plotWidth);
71
+ canvasImage.setAttribute('height', plotHeight);
72
+ canvasImage.setAttribute('href', tempCanvas.toDataURL('image/png'));
73
+ svg.appendChild(canvasImage);
74
+ } else {
75
+ svg.setAttribute('width', this.chart.width);
76
+ svg.setAttribute('height', this.chart.height);
77
+ svg.setAttribute('viewBox', `0 0 ${this.chart.width} ${this.chart.height}`);
78
+
79
+ const background = document.createElementNS(svgNamespace, 'rect');
80
+ background.setAttribute('width', this.chart.width);
81
+ background.setAttribute('height', this.chart.height);
82
+ background.setAttribute('fill', 'white');
83
+ svg.appendChild(background);
84
+
85
+ const canvasImage = document.createElementNS(svgNamespace, 'image');
86
+ canvasImage.setAttribute('width', this.chart.width);
87
+ canvasImage.setAttribute('height', this.chart.height);
88
+ canvasImage.setAttribute('href', this.chart.canvas.toDataURL('image/png'));
89
+ svg.appendChild(canvasImage);
90
+ }
91
+
92
+ const serializer = new XMLSerializer();
93
+ const svgString = serializer.serializeToString(svg);
94
+
95
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
96
+ const url = URL.createObjectURL(blob);
97
+ const link = document.createElement('a');
98
+
99
+ const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
100
+
101
+ link.setAttribute('href', url);
102
+ link.setAttribute('download', `${fileName}.svg`);
103
+ link.style.display = 'none';
104
+
105
+ document.body.appendChild(link);
106
+ link.click();
107
+ document.body.removeChild(link);
108
+ URL.revokeObjectURL(url);
109
+ }
110
+
111
+ downloadPNG() {
112
+ const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
113
+
114
+ let dataURL;
115
+
116
+ if (useZoomedArea) {
117
+ const tempCanvas = document.createElement('canvas');
118
+ tempCanvas.width = plotWidth;
119
+ tempCanvas.height = plotHeight;
120
+ const tempCtx = tempCanvas.getContext('2d');
121
+
122
+ tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
123
+
124
+ dataURL = tempCanvas.toDataURL('image/png');
125
+ } else {
126
+ dataURL = this.chart.canvas.toDataURL('image/png');
127
+ }
128
+
129
+ const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
130
+
131
+ const link = document.createElement('a');
132
+ link.setAttribute('href', dataURL);
133
+ link.setAttribute('download', `${fileName}.png`);
134
+ link.style.display = 'none';
135
+
136
+ document.body.appendChild(link);
137
+ link.click();
138
+ document.body.removeChild(link);
139
+ }
140
+ }
src/aspara/dashboard/static/js/chart/interaction-utils.js ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure utility functions for chart interaction
3
+ * These functions have no side effects and are easy to test
4
+ */
5
+
6
+ /**
7
+ * Calculate data ranges from series data (SoA format)
8
+ * @param {Array} series - Array of series objects with data in SoA format { steps: [], values: [] }
9
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null if no data
10
+ */
11
+ export function calculateDataRanges(series) {
12
+ let xMin = Number.POSITIVE_INFINITY;
13
+ let xMax = Number.NEGATIVE_INFINITY;
14
+ let yMin = Number.POSITIVE_INFINITY;
15
+ let yMax = Number.NEGATIVE_INFINITY;
16
+
17
+ for (const s of series) {
18
+ if (!s.data?.steps?.length) continue;
19
+ const { steps, values } = s.data;
20
+
21
+ // steps are sorted, so O(1) for min/max
22
+ xMin = Math.min(xMin, steps[0]);
23
+ xMax = Math.max(xMax, steps[steps.length - 1]);
24
+
25
+ // values min/max
26
+ for (let i = 0; i < values.length; i++) {
27
+ if (values[i] < yMin) yMin = values[i];
28
+ if (values[i] > yMax) yMax = values[i];
29
+ }
30
+ }
31
+
32
+ return xMin === Number.POSITIVE_INFINITY ? null : { xMin, xMax, yMin, yMax };
33
+ }
34
+
35
+ /**
36
+ * Binary search to find the nearest step in sorted steps array (SoA format)
37
+ * @param {Array} steps - Sorted array of step values
38
+ * @param {number} targetStep - Target step value
39
+ * @returns {Object|null} Object with { index, step } or null if empty
40
+ */
41
+ export function binarySearchNearestStep(steps, targetStep) {
42
+ if (!steps?.length) return null;
43
+ if (steps.length === 1) return { index: 0, step: steps[0] };
44
+
45
+ let left = 0;
46
+ let right = steps.length - 1;
47
+
48
+ // Handle edge cases: target is outside data range
49
+ if (targetStep <= steps[left]) return { index: left, step: steps[left] };
50
+ if (targetStep >= steps[right]) return { index: right, step: steps[right] };
51
+
52
+ // Binary search to find the two closest points
53
+ while (left < right - 1) {
54
+ const mid = (left + right) >> 1;
55
+ if (steps[mid] <= targetStep) {
56
+ left = mid;
57
+ } else {
58
+ right = mid;
59
+ }
60
+ }
61
+
62
+ // Compare left and right to find nearest
63
+ const leftDist = Math.abs(steps[left] - targetStep);
64
+ const rightDist = Math.abs(steps[right] - targetStep);
65
+ return leftDist <= rightDist ? { index: left, step: steps[left] } : { index: right, step: steps[right] };
66
+ }
67
+
68
+ /**
69
+ * Binary search to find a point by step value (SoA format)
70
+ * @param {Array} steps - Sorted array of step values
71
+ * @param {Array} values - Array of values corresponding to steps
72
+ * @param {number} step - Step value to find
73
+ * @returns {Object|null} Object with { index, step, value } or null if not found
74
+ */
75
+ export function binarySearchByStep(steps, values, step) {
76
+ if (!steps?.length) return null;
77
+
78
+ let left = 0;
79
+ let right = steps.length - 1;
80
+
81
+ while (left <= right) {
82
+ const mid = (left + right) >> 1;
83
+ if (steps[mid] === step) {
84
+ return { index: mid, step: steps[mid], value: values[mid] };
85
+ }
86
+ if (steps[mid] < step) {
87
+ left = mid + 1;
88
+ } else {
89
+ right = mid - 1;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Find the nearest step using binary search (SoA format, optimized version)
98
+ * Handles LTTB downsampling where each series may have different steps.
99
+ * @param {number} mouseX - Mouse X coordinate
100
+ * @param {Array} series - Series data in SoA format (may have different steps per series due to LTTB)
101
+ * @param {number} margin - Chart margin
102
+ * @param {number} plotWidth - Plot width
103
+ * @param {number} xMin - X axis minimum
104
+ * @param {number} xMax - X axis maximum
105
+ * @returns {number|null} Nearest step value or null
106
+ */
107
+ export function findNearestStepBinary(mouseX, series, margin, plotWidth, xMin, xMax) {
108
+ // Convert mouse X to target step value
109
+ const targetStep = xMin + ((mouseX - margin) / plotWidth) * (xMax - xMin);
110
+
111
+ let nearestStep = null;
112
+ let minDistance = Number.POSITIVE_INFINITY;
113
+
114
+ // Search each series for the nearest step (handles LTTB where series have different steps)
115
+ // Complexity: O(N × log M) where N = series count, M = points per series
116
+ for (const s of series) {
117
+ if (!s.data?.steps?.length) continue;
118
+
119
+ // Binary search to find nearest step in this series
120
+ const result = binarySearchNearestStep(s.data.steps, targetStep);
121
+ if (result === null) continue;
122
+
123
+ const distance = Math.abs(result.step - targetStep);
124
+ if (distance < minDistance) {
125
+ minDistance = distance;
126
+ nearestStep = result.step;
127
+ }
128
+ }
129
+
130
+ return nearestStep;
131
+ }